165 lines
5.4 KiB
JavaScript
165 lines
5.4 KiB
JavaScript
// Preisvergleich zwischen Märkten.
|
||
//
|
||
// Bewusst keine Matrix Artikel × Markt: Bei vier Märkten und dreißig
|
||
// Artikeln wird die auf einem Telefon unlesbar. Stattdessen je Artikel
|
||
// eine Karte mit den Märkten nach Preis sortiert - man sucht ohnehin
|
||
// "wo ist X am günstigsten", nicht "wie sieht die ganze Tabelle aus".
|
||
"use strict";
|
||
|
||
import { get } from "../api.js";
|
||
import { el, euro, formatQuantity, mount } from "../dom.js";
|
||
|
||
let expandedId = null;
|
||
|
||
function formatDate(iso) {
|
||
return new Date(iso).toLocaleDateString("de-DE", {
|
||
day: "2-digit", month: "2-digit", year: "2-digit",
|
||
});
|
||
}
|
||
|
||
/** Preis je Einheit, geliefert in Zehntelcent. */
|
||
function unitPrice(deci, unit) {
|
||
if (!deci || !unit) return null;
|
||
return `${euro(deci / 10)} je ${unit}`;
|
||
}
|
||
|
||
export async function pricesView(root, { listId, back }) {
|
||
let data = null;
|
||
let banner = null;
|
||
let detail = null;
|
||
|
||
async function reload() {
|
||
data = await get(`/api/lists/${listId}/prices`);
|
||
}
|
||
|
||
function say(message) {
|
||
banner = message;
|
||
render();
|
||
setTimeout(() => { banner = null; render(); }, 6000);
|
||
}
|
||
|
||
async function toggle(articleId) {
|
||
if (expandedId === articleId) {
|
||
expandedId = null;
|
||
detail = null;
|
||
render();
|
||
return;
|
||
}
|
||
expandedId = articleId;
|
||
detail = null;
|
||
render();
|
||
try {
|
||
detail = await get(`/api/lists/${listId}/articles/${articleId}/prices`);
|
||
render();
|
||
} catch (err) {
|
||
say(err.message);
|
||
}
|
||
}
|
||
|
||
function marketRow(row, marketId, cents, isBest) {
|
||
return el("div", { className: `price-row${isBest ? " best" : ""}` },
|
||
el("span.market", {}, data.market_names[marketId] || "?"),
|
||
el("span.amount", {}, euro(cents)),
|
||
isBest && row.spread_cents > 0
|
||
? el("span.badge", {}, "günstigster")
|
||
: row.spread_cents > 0
|
||
? el("span.diff", {}, `+${euro(cents - row.best_cents)}`)
|
||
: null
|
||
);
|
||
}
|
||
|
||
function detailBlock() {
|
||
if (!detail) return el("p.loading", {}, "Wird geladen …");
|
||
|
||
return el("div.price-detail", {},
|
||
el("h4", {}, "Beobachtungen je Markt"),
|
||
el("ul.price-markets", {}, detail.markets.map((m) =>
|
||
el("li", {},
|
||
el("span.market", {}, m.market_name),
|
||
el("span.sub", {},
|
||
m.latest_pack_size
|
||
? `${formatQuantity(m.latest_pack_size)} ${m.latest_pack_unit || ""} · `
|
||
: "",
|
||
unitPrice(m.unit_price_deci, m.latest_pack_unit)
|
||
? `${unitPrice(m.unit_price_deci, m.latest_pack_unit)} · `
|
||
: "",
|
||
m.observations === 1
|
||
? "einmal erfasst"
|
||
: `${m.observations}× erfasst, ${euro(m.min_cents)}–${euro(m.max_cents)}`,
|
||
` · zuletzt ${formatDate(m.latest_at)}`),
|
||
el("span.amount", {}, euro(m.latest_cents))))),
|
||
|
||
detail.history.length > 1
|
||
? el("details.price-history", {},
|
||
el("summary", {}, `Verlauf (${detail.history.length} Einträge)`),
|
||
el("ul", {}, detail.history.map((h) =>
|
||
el("li", {},
|
||
`${formatDate(h.recorded_at)} · ${h.market_name} · ${euro(h.price_cents)}`,
|
||
h.pack_size
|
||
? ` (${formatQuantity(h.pack_size)} ${h.pack_unit || ""})` : ""))))
|
||
: null
|
||
);
|
||
}
|
||
|
||
function articleCard(row) {
|
||
const entries = Object.entries(row.prices).sort((a, b) => a[1] - b[1]);
|
||
|
||
return el("section.card.price-card", {},
|
||
el("button.price-head", {
|
||
type: "button",
|
||
onclick: () => toggle(row.article_id),
|
||
"aria-expanded": expandedId === row.article_id ? "true" : "false",
|
||
},
|
||
el("span.name", {}, row.article_name),
|
||
row.spread_cents > 0
|
||
? el("span.spread", {}, `bis ${euro(row.spread_cents)} Unterschied`)
|
||
: el("span.spread", {}, "überall gleich")),
|
||
|
||
el("div.price-rows", {}, entries.map(([marketId, cents]) =>
|
||
marketRow(row, marketId, cents, row.best_market_ids.includes(marketId)))),
|
||
|
||
expandedId === row.article_id ? detailBlock() : null
|
||
);
|
||
}
|
||
|
||
function render() {
|
||
const withSpread = data.rows.filter((r) => r.spread_cents > 0);
|
||
const savings = withSpread.reduce((sum, r) => sum + r.spread_cents, 0);
|
||
|
||
mount(root,
|
||
el("header.bar", {},
|
||
el("button.linklike", { type: "button", onclick: back },
|
||
"‹ Zurück zur Liste")),
|
||
|
||
banner ? el("p.error", {}, banner) : null,
|
||
|
||
el("section.card", {},
|
||
el("h1", {}, "Preise"),
|
||
data.rows.length
|
||
? el("p.lead", {},
|
||
`${data.rows.length} Artikel mit erfassten Preisen. `,
|
||
withSpread.length
|
||
? `Bei ${withSpread.length} davon unterscheiden sich die Märkte – ` +
|
||
`zusammen ${euro(savings)} Unterschied, wenn jeder Artikel ` +
|
||
`im günstigsten Markt gekauft würde.`
|
||
: "Bislang gibt es keine Preisunterschiede zwischen den Märkten.")
|
||
: el("p.empty", {},
|
||
"Noch keine Preise erfasst. Trag beim Einkaufen Preise an den " +
|
||
"Artikeln ein – sobald ein Eintrag einem Markt zugeordnet ist, " +
|
||
"wird der Preis hier gesammelt.")),
|
||
|
||
data.rows.map(articleCard),
|
||
|
||
data.rows.length
|
||
? el("p.footnote", {},
|
||
"Erfasst werden Markt, Artikel, Betrag und Zeitpunkt – ohne " +
|
||
"Angabe, wer den Preis eingetragen hat.")
|
||
: null
|
||
);
|
||
}
|
||
|
||
await reload();
|
||
render();
|
||
return render;
|
||
}
|