Skip to content

Commit 2b024e9

Browse files
committed
feat: add framework to render json on orderhisotry and added graph tooltips
1 parent 5cfb694 commit 2b024e9

4 files changed

Lines changed: 145 additions & 9 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "simplestrichliste",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"description": "",
55
"main": "index.js",
66
"scripts": {

views/admin/stats.ejs

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
border-color: #2563eb;
2424
color: #ffffff;
2525
}
26+
27+
.stats-tooltip {
28+
pointer-events: none;
29+
transform: translate(-50%, calc(-100% - 12px));
30+
}
2631
</style>
2732
</head>
2833

@@ -187,6 +192,76 @@
187192
`).join("");
188193
}
189194
195+
function getChartX(labels, margins, plotWidth, index) {
196+
return labels.length === 1
197+
? margins.left + plotWidth / 2
198+
: margins.left + (plotWidth * index / (labels.length - 1));
199+
}
200+
201+
function getOrCreateTooltip(canvas) {
202+
const parent = canvas.parentElement;
203+
let tooltip = parent.querySelector(".stats-tooltip");
204+
if (!tooltip) {
205+
tooltip = document.createElement("div");
206+
tooltip.className = "stats-tooltip hidden absolute z-20 max-w-xs rounded-lg bg-gray-900 px-3 py-2 text-xs text-white shadow-lg";
207+
parent.appendChild(tooltip);
208+
}
209+
return tooltip;
210+
}
211+
212+
function bindChartTooltip(canvas, labels, datasets, revenueDataset, margins, plotWidth, plotHeight) {
213+
const tooltip = getOrCreateTooltip(canvas);
214+
const allDatasets = revenueDataset ? [...datasets, revenueDataset] : datasets;
215+
const formatValue = (dataset, value) => dataset === revenueDataset
216+
? moneyFormatter.format(value)
217+
: numberFormatter.format(value);
218+
219+
canvas.onmousemove = (event) => {
220+
const rect = canvas.getBoundingClientRect();
221+
const x = event.clientX - rect.left;
222+
const y = event.clientY - rect.top;
223+
224+
if (
225+
x < margins.left ||
226+
x > margins.left + plotWidth ||
227+
y < margins.top ||
228+
y > margins.top + plotHeight ||
229+
labels.length === 0
230+
) {
231+
tooltip.classList.add("hidden");
232+
return;
233+
}
234+
235+
const rawIndex = labels.length === 1
236+
? 0
237+
: Math.round(((x - margins.left) / plotWidth) * (labels.length - 1));
238+
const index = Math.min(Math.max(rawIndex, 0), labels.length - 1);
239+
const tooltipX = getChartX(labels, margins, plotWidth, index);
240+
241+
tooltip.innerHTML = `
242+
<div class="mb-1 font-semibold text-gray-100">${labels[index]}</div>
243+
<div class="space-y-1">
244+
${allDatasets.map((dataset) => `
245+
<div class="flex min-w-0 items-center justify-between gap-4">
246+
<span class="flex min-w-0 items-center gap-2">
247+
<span class="inline-block h-2.5 w-2.5 flex-shrink-0 rounded-full" style="background:${dataset.color}"></span>
248+
<span class="truncate">${dataset.name}</span>
249+
</span>
250+
<span class="font-mono">${formatValue(dataset, dataset.values[index] || 0)}</span>
251+
</div>
252+
`).join("")}
253+
</div>
254+
`;
255+
tooltip.style.left = `${tooltipX}px`;
256+
tooltip.style.top = `${margins.top + 8}px`;
257+
tooltip.classList.remove("hidden");
258+
};
259+
260+
canvas.onmouseleave = () => {
261+
tooltip.classList.add("hidden");
262+
};
263+
}
264+
190265
function drawLineChart(canvasId, legendId, labels, datasets, options = {}) {
191266
const canvas = document.getElementById(canvasId);
192267
const ctx = canvas.getContext("2d");
@@ -250,7 +325,7 @@
250325
ctx.textBaseline = "top";
251326
labels.forEach((label, index) => {
252327
if (index % labelStep !== 0 && index !== labels.length - 1) return;
253-
const x = labels.length === 1 ? margins.left + plotWidth / 2 : margins.left + (plotWidth * index / (labels.length - 1));
328+
const x = getChartX(labels, margins, plotWidth, index);
254329
ctx.fillText(label, x, margins.top + plotHeight + 14);
255330
});
256331
@@ -260,7 +335,7 @@
260335
ctx.setLineDash(dashed ? [6, 5] : []);
261336
ctx.beginPath();
262337
dataset.values.forEach((value, index) => {
263-
const x = labels.length === 1 ? margins.left + plotWidth / 2 : margins.left + (plotWidth * index / (labels.length - 1));
338+
const x = getChartX(labels, margins, plotWidth, index);
264339
const y = margins.top + plotHeight - (plotHeight * value / Math.max(maxValue, 1));
265340
if (index === 0) ctx.moveTo(x, y);
266341
else ctx.lineTo(x, y);
@@ -273,6 +348,7 @@
273348
if (revenueDataset) drawDataset(revenueDataset, rightMax, true);
274349
275350
drawLegend(legendId, datasets, revenueDataset);
351+
bindChartTooltip(canvas, labels, datasets, revenueDataset, margins, plotWidth, plotHeight);
276352
}
277353
278354
function seriesFromRows(rows, labels, keyField, nameField, valueField, knownItems = []) {

views/admin/transaction_history.ejs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
<script>
99
const lang = <%- JSON.stringify(language) %>;
1010
const features = <%- JSON.stringify(features) %>;
11+
const transactionHistoryFormatters = <%- JSON.stringify(Object.fromEntries(Object.entries(features || {})
12+
.map(([featureName, feature]) => [featureName, feature?.transactionHistory?.customItemText])
13+
.filter(([, formatter]) => formatter?.template && typeof formatter.template === "string"))) %>;
1114
</script>
1215
<script src="<%= domain %>/libjs/i18next.js?<%= curentUnixTime %>"></script>
1316
<script src="<%= domain %>/libjs/tailwind.js?<%= curentUnixTime %>"></script>
@@ -117,6 +120,33 @@
117120
};
118121
};
119122
123+
const escapeHTML = (value) => String(value ?? "")
124+
.replace(/&/g, "&amp;")
125+
.replace(/</g, "&lt;")
126+
.replace(/>/g, "&gt;")
127+
.replace(/"/g, "&quot;")
128+
.replace(/'/g, "&#039;");
129+
130+
const formatCustomItemText = (value) => {
131+
if (!value) return "";
132+
try {
133+
const payload = JSON.parse(value);
134+
const formatter = payload?.feature ? transactionHistoryFormatters[payload.feature] : null;
135+
if (formatter?.template) {
136+
const formatted = formatter.template.replace(/\{([^}]+)\}/g, (match, expression) => {
137+
const keys = expression.split("|").map((key) => key.trim()).filter(Boolean);
138+
const replacement = keys.map((key) => payload[key]).find((candidate) => candidate !== undefined && candidate !== null && candidate !== "");
139+
return replacement === undefined ? "" : String(replacement);
140+
}).replace(/\s+/g, " ").trim();
141+
142+
if (formatted) return escapeHTML(formatted);
143+
}
144+
} catch (error) {
145+
// Old custom text was stored as plain text.
146+
}
147+
return escapeHTML(value);
148+
};
149+
120150
const createTransactionHTML = (tx) => {
121151
const { time } = formatTimestamp(tx.transaction_timestamp);
122152
let totalAmount = tx.price_at_transaction * tx.quantity;
@@ -129,7 +159,7 @@
129159
transaction_type = "purchase";
130160
if (tx.custom_item_text) {
131161
totalAmount = totalAmount * -1;
132-
title = tx.custom_item_text;
162+
title = formatCustomItemText(tx.custom_item_text);
133163
} else {
134164
title = i18next.t("Items.SystemTransaction");
135165
}
@@ -147,8 +177,8 @@
147177
default:
148178
transaction_type = "purchase";
149179
totalAmount = totalAmount * -1; // Invert amount for purchases
150-
title = i18next.t("Items.PurchaseTitle", { count: tx.quantity, name: tx.item_name });
151-
iconOrImage = `<img src="/i/items/${tx.item_uuid}" alt="${tx.item_name}" class="w-12 h-12 object-cover rounded-full">`;
180+
title = escapeHTML(i18next.t("Items.PurchaseTitle", { count: tx.quantity, name: tx.item_name }));
181+
iconOrImage = `<img src="/i/items/${encodeURIComponent(tx.item_uuid)}" alt="${escapeHTML(tx.item_name)}" class="w-12 h-12 object-cover rounded-full">`;
152182
}
153183
154184
const sign = totalAmount > 0 ? "+" : totalAmount < 0 ? "-" : "";

views/transaction_history.ejs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
<script>
99
const lang = <%- JSON.stringify(language) %>;
1010
const features = <%- JSON.stringify(features) %>;
11+
const transactionHistoryFormatters = <%- JSON.stringify(Object.fromEntries(Object.entries(features || {})
12+
.map(([featureName, feature]) => [featureName, feature?.transactionHistory?.customItemText])
13+
.filter(([, formatter]) => formatter?.template && typeof formatter.template === "string"))) %>;
1114
</script>
1215
<script src="<%= domain %>/libjs/i18next.js?<%= curentUnixTime %>"></script>
1316
<script src="<%= domain %>/libjs/tailwind.js?<%= curentUnixTime %>"></script>
@@ -113,6 +116,33 @@
113116
};
114117
};
115118
119+
const escapeHTML = (value) => String(value ?? "")
120+
.replace(/&/g, "&amp;")
121+
.replace(/</g, "&lt;")
122+
.replace(/>/g, "&gt;")
123+
.replace(/"/g, "&quot;")
124+
.replace(/'/g, "&#039;");
125+
126+
const formatCustomItemText = (value) => {
127+
if (!value) return "";
128+
try {
129+
const payload = JSON.parse(value);
130+
const formatter = payload?.feature ? transactionHistoryFormatters[payload.feature] : null;
131+
if (formatter?.template) {
132+
const formatted = formatter.template.replace(/\{([^}]+)\}/g, (match, expression) => {
133+
const keys = expression.split("|").map((key) => key.trim()).filter(Boolean);
134+
const replacement = keys.map((key) => payload[key]).find((candidate) => candidate !== undefined && candidate !== null && candidate !== "");
135+
return replacement === undefined ? "" : String(replacement);
136+
}).replace(/\s+/g, " ").trim();
137+
138+
if (formatted) return escapeHTML(formatted);
139+
}
140+
} catch (error) {
141+
// Old custom text was stored as plain text.
142+
}
143+
return escapeHTML(value);
144+
};
145+
116146
const createTransactionHTML = (tx) => {
117147
const { time } = formatTimestamp(tx.transaction_timestamp);
118148
let totalAmount = tx.price_at_transaction * tx.quantity;
@@ -125,7 +155,7 @@
125155
transaction_type = "purchase";
126156
if (tx.custom_item_text) {
127157
totalAmount = totalAmount * -1;
128-
title = tx.custom_item_text;
158+
title = formatCustomItemText(tx.custom_item_text);
129159
} else {
130160
title = i18next.t("Items.SystemTransaction");
131161
}
@@ -143,8 +173,8 @@
143173
default:
144174
transaction_type = "purchase";
145175
totalAmount = totalAmount * -1; // Invert amount for purchases
146-
title = i18next.t("Items.PurchaseTitle", { count: tx.quantity, name: tx.item_name });
147-
iconOrImage = `<img src="/i/items/${tx.item_uuid}" alt="${tx.item_name}" class="w-12 h-12 object-cover rounded-full">`;
176+
title = escapeHTML(i18next.t("Items.PurchaseTitle", { count: tx.quantity, name: tx.item_name }));
177+
iconOrImage = `<img src="/i/items/${encodeURIComponent(tx.item_uuid)}" alt="${escapeHTML(tx.item_name)}" class="w-12 h-12 object-cover rounded-full">`;
148178
}
149179
150180
const sign = totalAmount > 0 ? "+" : totalAmount < 0 ? "-" : "";

0 commit comments

Comments
 (0)