Files
einkaufsapp/web/html/js/views/prices.js
2026-08-09 18:44:21 +02:00

164 lines
5.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
import { appBar } from "../appbar.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,
appBar({ back: { label: "Zurück", onClick: back }, title: "Preise" }),
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;
}