Erste Produktivversion
This commit is contained in:
164
web/html/js/views/prices.js
Normal file
164
web/html/js/views/prices.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user