mirror of
https://github.com/badlogic/heissepreise.git
synced 2024-06-22 08:25:52 +02:00
Charts, various fixes.
This commit is contained in:
parent
0df29311f7
commit
416db5b5b4
17
package-lock.json
generated
17
package-lock.json
generated
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"alasql": "^4.0.4",
|
||||
"axios": "^1.4.0",
|
||||
"chart.js": "^4.3.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"compression": "^1.7.4",
|
||||
"express": "^4.18.2",
|
||||
|
@ -460,6 +461,11 @@
|
|||
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
|
||||
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
@ -917,6 +923,17 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.0.tgz",
|
||||
"integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
"dependencies": {
|
||||
"alasql": "^4.0.4",
|
||||
"axios": "^1.4.0",
|
||||
"chart.js": "^4.3.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"compression": "^1.7.4",
|
||||
"express": "^4.18.2",
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="w-full relative px-4 flex-1">
|
||||
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">Preisänderungen</h1>
|
||||
<items-filter x-id="items-filter" pricechanges pricedirection stores misc placeholder="Filtern..."></items-filter>
|
||||
<items-list></items-list>
|
||||
<items-list chart updown add remove></items-list>
|
||||
</div>
|
||||
|
||||
<script src="changes-new.js"></script>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="w-full relative px-4 flex-1">
|
||||
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">Produktsuche</h1>
|
||||
<items-filter x-id="items-filter" emptyquery stores misc></items-filter>
|
||||
<items-list></items-list>
|
||||
<items-list chart></items-list>
|
||||
</div>
|
||||
|
||||
<script src="index-new.js"></script>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
canvas.hidden {
|
||||
*.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
@ -184,3 +184,7 @@ thead > tr {
|
|||
@apply md:mt-6;
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
185
site/views/items-chart.js
Normal file
185
site/views/items-chart.js
Normal file
|
@ -0,0 +1,185 @@
|
|||
const { STORE_KEYS } = require("../model/stores");
|
||||
const { today } = require("../misc");
|
||||
const { View } = require("./view");
|
||||
require("./custom-checkbox");
|
||||
const { Chart, registerables } = require("chart.js");
|
||||
Chart.register(...registerables);
|
||||
|
||||
class ItemsChart extends View {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.innerHTML = /*html*/ `
|
||||
<div class="bg-stone-200 p-4 mx-auto">
|
||||
<canvas x-id="canvas" class="bg-white rounded-lg py-4"></canvas>
|
||||
<div class="filters flex items-center flex-wrap justify-center gap-2 pt-2">
|
||||
<custom-checkbox x-id="sumTotal" x-change label="Preissumme Gesamt"></custom-checkbox>
|
||||
<custom-checkbox x-id="sumStores" x-change label="Preissumme Ketten"></custom-checkbox>
|
||||
<custom-checkbox x-id="onlyToday" x-change label="Nur heutige Preise"></custom-checkbox>
|
||||
<div
|
||||
class="cursor-pointer inline-flex items-center gap-x-1 rounded-full bg-white border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600">
|
||||
<input x-id="startDate" x-change type="date" value="2020-01-01" />
|
||||
-
|
||||
<input x-id="endDate" x-change type="date" value="${today()}"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.setupEventHandlers();
|
||||
this.addEventListener("change", () => {
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
calculateOverallPriceChanges(items, onlyToday, startDate, endDate) {
|
||||
if (items.length == 0) return { dates: [], changes: [] };
|
||||
|
||||
if (onlyToday) {
|
||||
let sum = 0;
|
||||
for (const item of items) sum += item.price;
|
||||
return [{ date: today(), price: sum }];
|
||||
}
|
||||
|
||||
const allDates = items.flatMap((product) => product.priceHistory.map((item) => item.date));
|
||||
let uniqueDates = [...new Set(allDates)];
|
||||
uniqueDates.sort();
|
||||
|
||||
const allPrices = items.map((product) => {
|
||||
let price = null;
|
||||
const prices = uniqueDates.map((date) => {
|
||||
const priceObj = product.priceHistory.find((item) => item.date === date);
|
||||
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++) {
|
||||
if (uniqueDates[i] < startDate || uniqueDates[i] > endDate) continue;
|
||||
let price = 0;
|
||||
for (let j = 0; j < allPrices.length; j++) {
|
||||
price += allPrices[j][i];
|
||||
}
|
||||
priceChanges.push({ date: uniqueDates[i], price });
|
||||
}
|
||||
|
||||
return priceChanges;
|
||||
}
|
||||
|
||||
renderChart(items, chartType) {
|
||||
const canvasDom = this.elements.canvas;
|
||||
if (items.length === 0) {
|
||||
canvasDom.classList.add("hidden");
|
||||
return;
|
||||
} else {
|
||||
canvasDom.classList.remove("hidden");
|
||||
}
|
||||
|
||||
const allDates = items.flatMap((product) => product.priceHistory.map((item) => item.date));
|
||||
const uniqueDates = [...new Set(allDates)];
|
||||
uniqueDates.sort();
|
||||
|
||||
const datasets = items.map((product) => {
|
||||
let price = null;
|
||||
const prices = uniqueDates.map((date) => {
|
||||
const priceObj = product.priceHistory.find((item) => item.date === date);
|
||||
if (!price && priceObj) price = priceObj.price;
|
||||
return priceObj ? priceObj.price : null;
|
||||
});
|
||||
|
||||
for (let i = 0; i < prices.length; i++) {
|
||||
if (prices[i] == null) {
|
||||
prices[i] = price;
|
||||
} else {
|
||||
price = prices[i];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: (product.store ? product.store + " " : "") + product.name,
|
||||
data: prices,
|
||||
};
|
||||
});
|
||||
|
||||
const ctx = canvasDom.getContext("2d");
|
||||
let scrollTop = -1;
|
||||
if (canvasDom.lastChart) {
|
||||
scrollTop = document.documentElement.scrollTop;
|
||||
canvasDom.lastChart.destroy();
|
||||
}
|
||||
canvasDom.lastChart = new Chart(ctx, {
|
||||
type: chartType ? chartType : "line",
|
||||
data: {
|
||||
labels: uniqueDates,
|
||||
datasets: datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
aspectRation: 16 / 9,
|
||||
scales: {
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "EURO",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (scrollTop != -1) document.documentElement.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.model) return;
|
||||
const items = this.model.filteredItems;
|
||||
const elements = this.elements;
|
||||
const onlyToday = this.elements.onlyToday.checked;
|
||||
const startDate = this.elements.startDate.value;
|
||||
const endDate = this.elements.endDate.value;
|
||||
const itemsToShow = [];
|
||||
|
||||
if (elements.sumTotal.checked && items.length > 0) {
|
||||
itemsToShow.push({
|
||||
name: "Preissumme Gesamt",
|
||||
priceHistory: this.calculateOverallPriceChanges(items, onlyToday, startDate, endDate),
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.sumStores.checked && items.length > 0) {
|
||||
STORE_KEYS.forEach((store) => {
|
||||
const storeItems = items.filter((item) => item.store === store);
|
||||
if (storeItems.length > 0) {
|
||||
itemsToShow.push({
|
||||
name: "Preissumme " + store,
|
||||
priceHistory: this.calculateOverallPriceChanges(storeItems, onlyToday, startDate, endDate),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.chart) {
|
||||
itemsToShow.push({
|
||||
name: item.store + " " + item.name,
|
||||
priceHistory: onlyToday
|
||||
? [{ date: today(), price: item.price }]
|
||||
: item.priceHistory.filter((price) => price.date >= startDate && price.date <= endDate),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Items to show " + itemsToShow.length);
|
||||
|
||||
this.renderChart(itemsToShow, onlyToday ? "bar" : "line");
|
||||
}
|
||||
}
|
||||
customElements.define("items-chart", ItemsChart);
|
|
@ -19,7 +19,7 @@ class ItemsFilter extends View {
|
|||
const placeholder = this.hasAttribute("placeholder") ? this.getAttribute("placeholder") : "Produkte suchen...";
|
||||
|
||||
this.innerHTML = /*html*/ `
|
||||
<input x-id="query" x-state x-input-debounce class="rounded-lg px-2 py-1 w-full" type="text" placeholder="${placeholder}" />
|
||||
<input x-id="query" x-state x-input class="rounded-lg px-2 py-1 w-full" type="text" placeholder="${placeholder}" />
|
||||
|
||||
<div x-id="stores" class="flex justify-center gap-2 flex-wrap mt-4 ${hideStores}">
|
||||
<custom-checkbox x-id="allStores" label="Alle" checked></custom-checkbox>
|
||||
|
@ -104,29 +104,9 @@ class ItemsFilter extends View {
|
|||
|
||||
this.setupEventHandlers();
|
||||
|
||||
this.addEventListener("change", () => {
|
||||
this.addEventListener("change", (event) => {
|
||||
this.filter();
|
||||
});
|
||||
|
||||
let stateParents = [this];
|
||||
View.traverse(
|
||||
this,
|
||||
[],
|
||||
(parents, element) => {
|
||||
if (element.hasAttribute("x-state")) {
|
||||
console.log(
|
||||
(stateParents.length > 0 ? stateParents.map((p) => p.getAttribute("x-id")).join(" -> ") + " -> " : "") +
|
||||
element.getAttribute("x-id")
|
||||
);
|
||||
stateParents.push(element);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
(parents, element) => {
|
||||
if (element.hasAttribute("x-state")) stateParents.pop();
|
||||
}
|
||||
);
|
||||
console.log(this.state);
|
||||
}
|
||||
|
||||
filter() {
|
||||
|
@ -216,6 +196,11 @@ class ItemsFilter extends View {
|
|||
filteredItems = queryItems(query, filteredItems);
|
||||
}
|
||||
|
||||
if (this._lastQuery != query) {
|
||||
filteredItems.forEach((item) => (item.chart = false));
|
||||
}
|
||||
this._lastQuery = query;
|
||||
|
||||
console.log("Filtering items took " + (performance.now() - now) / 1000 + " secs");
|
||||
|
||||
this.model.removeListener(this._listener);
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
const { downloadJSON, dom, onVisibleOnce, isMobile } = require("../misc");
|
||||
const { downloadJSON, dom, onVisibleOnce, isMobile, getBooleanAttribute } = require("../misc");
|
||||
const { vectorizeItems, similaritySortItems } = require("../knn");
|
||||
const { stores } = require("../model/stores");
|
||||
const { View } = require("./view");
|
||||
const { ItemsChart } = require("./items-chart");
|
||||
|
||||
class ItemsList extends View {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._share = getBooleanAttribute(this, "share");
|
||||
this._json = getBooleanAttribute(this, "json");
|
||||
this._chart = getBooleanAttribute(this, "chart");
|
||||
this._remove = getBooleanAttribute(this, "remove");
|
||||
this._remove = getBooleanAttribute(this, "add");
|
||||
this._updown = getBooleanAttribute(this, "updown");
|
||||
|
||||
this.innerHTML = /*html*/ `
|
||||
<div class="flex flex-col md:flex-row gap-4 px-4 py-2 my-4 justify-between items-center text-sm border rounded-xl md:mt-8 md:rounded-b-none md:mb-0 bg-gray-100 ">
|
||||
<div>
|
||||
<div class="flex flex-col md:flex-row gap-2 items-center">
|
||||
<span x-id="numItems"></span>
|
||||
<span>
|
||||
<a x-id="shareLink" class="querylink text-primary font-medium hover:underline">Teilen</a>
|
||||
<a x-id="json" class="text-primary font-medium hover:underline" href="">JSON</a>
|
||||
<a x-id="shareLink" class="hidden querylink text-primary font-medium hover:underline">Teilen</a>
|
||||
<a x-id="json" class="hidden text-primary font-medium hover:underline" href="">JSON</a>
|
||||
</span>
|
||||
<custom-checkbox x-id="chart" label="Diagramm"></custom-checkbox>
|
||||
<custom-checkbox x-id="enableChart" label="Diagramm" class="${this._chart}"></custom-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
|
@ -31,6 +39,7 @@ class ItemsList extends View {
|
|||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<items-chart x-id="chart" class="hidden"></items-chart>
|
||||
<table class="rounded-b-xl overflow-hidden w-full text-left">
|
||||
<thead>
|
||||
<tr class="bg-primary text-white hidden md:table-row uppercase text-sm">
|
||||
|
@ -45,33 +54,25 @@ class ItemsList extends View {
|
|||
</table>
|
||||
`;
|
||||
|
||||
this._itemTemplate = dom(
|
||||
"tr",
|
||||
/*html*/ `
|
||||
<td x-id="store" data-label="Kette"></td>
|
||||
<td data-label="Name">
|
||||
<div class="flex items-center">
|
||||
<a x-id="name" target="_blank" class="hover:underline" rel="noopener noreferrer nofollow" href=""></a>
|
||||
<small x-id="quantity" class="ml-auto"></small>
|
||||
</div>
|
||||
<table x-id="priceHistory" class="priceinfo hidden" aria-hidden="true">
|
||||
</table>
|
||||
</td>
|
||||
<td data-label="Preis">
|
||||
<span x-id="price"></span>
|
||||
<span x-id="percentageChange"></span>
|
||||
<span x-id="numPrices"></span>
|
||||
<span class="chevron">▼</span>
|
||||
</td>
|
||||
`
|
||||
);
|
||||
|
||||
const elements = this.elements;
|
||||
|
||||
if (!this._share) elements.shareLink.classList.remove("hidden");
|
||||
if (!this._json) elements.json.classList.remove("hidden");
|
||||
if (!this._chart) elements.chart.classList.remove("hidden");
|
||||
|
||||
elements.json.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (!this.model) return;
|
||||
downloadJSON("items.json", this.model.filteredItems);
|
||||
this.download(this.model.filteredItems);
|
||||
});
|
||||
|
||||
elements.enableChart.addEventListener("change", () => {
|
||||
if (elements.enableChart.checked) elements.chart.classList.remove("hidden");
|
||||
else elements.chart.classList.add("hidden");
|
||||
});
|
||||
|
||||
elements.chart.addEventListener("change", (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
// Cache in a field, so we don't have to call this.elements in each renderItem() call.
|
||||
|
@ -87,6 +88,34 @@ class ItemsList extends View {
|
|||
});
|
||||
}
|
||||
|
||||
set model(model) {
|
||||
super.model = model;
|
||||
this.elements.chart.model = model;
|
||||
}
|
||||
|
||||
get model() {
|
||||
return super.model;
|
||||
}
|
||||
|
||||
download(items) {
|
||||
const cleanedItems = [];
|
||||
items.forEach((item) => {
|
||||
cleanedItems.push({
|
||||
store: item.store,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
priceHistory: item.priceHistory,
|
||||
isWeighted: item.isWeighted,
|
||||
unit: item.unit,
|
||||
quantity: item.quantity,
|
||||
bio: item.bio,
|
||||
url: item.url,
|
||||
});
|
||||
});
|
||||
downloadJSON("items.json", cleanedItems);
|
||||
}
|
||||
|
||||
sort(items) {
|
||||
const sortType = this.elements.sort.value;
|
||||
if (sortType == "price-asc") {
|
||||
|
@ -127,6 +156,39 @@ class ItemsList extends View {
|
|||
}
|
||||
|
||||
renderItem(item) {
|
||||
if (!this._itemTemplate) {
|
||||
this._itemTemplate = dom(
|
||||
"tr",
|
||||
/*html*/ `
|
||||
<td x-id="store" data-label="Kette"></td>
|
||||
<td data-label="Name">
|
||||
<div class="flex items-center">
|
||||
<a x-id="name" target="_blank" class="hover:underline" rel="noopener noreferrer nofollow" href=""></a>
|
||||
<small x-id="quantity" class="ml-auto"></small>
|
||||
</div>
|
||||
<table x-id="priceHistory" class="priceinfo hidden" aria-hidden="true">
|
||||
</table>
|
||||
</td>
|
||||
<td data-label="Preis">
|
||||
<span x-id="price"></span>
|
||||
<span x-id="percentageChange"></span>
|
||||
<span x-id="numPrices"></span>
|
||||
<span class="chevron">▼</span>
|
||||
</td>
|
||||
<td class="action">
|
||||
<label x-id="chart" class="${this._chart ? "" : "hidden"}">
|
||||
<input x-id="chartCheckbox" type="checkbox" class="hidden peer">
|
||||
<span class="peer-checked:bg-blue-700 btn-action">📈</span>
|
||||
</label>
|
||||
<input x-id="add" type="button" class="${this._remove ? "" : "hidden"} btn-action" value="+">
|
||||
<input x-id="remove" type="button" class="${this._remove ? "" : "hidden"} btn-action" value="-">
|
||||
<input x-id="up" type="button" class="${this._updown ? "" : "hidden"} btn-action" value="▲">
|
||||
<input x-id="down" type="button" class="${this._updown ? "" : "hidden"} btn-action" value="▼">
|
||||
</td>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
let quantity = item.quantity || "";
|
||||
let unit = item.unit || "";
|
||||
if (quantity >= 1000 && (unit == "g" || unit == "ml")) {
|
||||
|
@ -170,6 +232,7 @@ class ItemsList extends View {
|
|||
"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 = item.url;
|
||||
|
@ -200,6 +263,18 @@ class ItemsList extends View {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (this._chart) {
|
||||
elements.chartCheckbox.checked = item.chart;
|
||||
elements.chartCheckbox.addEventListener("click", () => {
|
||||
document.activeElement.blur();
|
||||
});
|
||||
elements.chartCheckbox.addEventListener("change", (event) => {
|
||||
event.stopPropagation();
|
||||
item.chart = elements.chartCheckbox.checked;
|
||||
this.elements.chart.render();
|
||||
});
|
||||
}
|
||||
if (this._showAllPriceHistories) elements.priceHistory.classList.remove("hidden");
|
||||
return itemDom;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
const { getBooleanAttribute } = require("../misc");
|
||||
|
||||
class View extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
@ -26,9 +28,23 @@ class View extends HTMLElement {
|
|||
}
|
||||
|
||||
static elements(view) {
|
||||
const elements = view.querySelectorAll("[x-id]");
|
||||
let elements = [...view.querySelectorAll("[x-id]")];
|
||||
elements = elements.filter((el) => {
|
||||
let parent = el.parentElement;
|
||||
while (parent != view) {
|
||||
if (parent instanceof View) return false;
|
||||
if (getBooleanAttribute(parent, "x-notraverse")) return false;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const result = {};
|
||||
elements.forEach((element) => (result[element.getAttribute("x-id")] = element));
|
||||
elements.forEach((element) => {
|
||||
if (result[element.getAttribute("x-id")]) {
|
||||
console.log(`Duplicate element x-id ${element.getAttribute("x-id")} in ${view.localName}`);
|
||||
}
|
||||
result[element.getAttribute("x-id")] = element;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user