Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

164
web/html/js/views/prices.js Normal file
View 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;
}