feat: sort and paginate usage history
This commit is contained in:
109
app.js
109
app.js
@@ -63,10 +63,12 @@ const LANGUAGE_CONFIG = Object.freeze({
|
||||
});
|
||||
const SUPPORTED_LANGUAGES = Object.freeze(Object.keys(LANGUAGE_CONFIG));
|
||||
const OWNER_PAGE_SIZE = 50;
|
||||
const USAGE_PAGE_SIZE = 30;
|
||||
let detailOwnerId = null;
|
||||
let activePage = "dashboard";
|
||||
let activeOwnerView = "accounts";
|
||||
let ownerPage = 1;
|
||||
let usagePage = 1;
|
||||
let stayReturnOwnerId = null;
|
||||
let stayOwnerLocked = false;
|
||||
let toastTimer;
|
||||
@@ -83,22 +85,51 @@ let sessionErrorTimer = null;
|
||||
let loginErrorKey = null;
|
||||
let sessionErrorKey = null;
|
||||
|
||||
function dateOnlyMs(value) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ""))) return Number.NaN;
|
||||
const [year, month, day] = String(value).split("-").map(Number);
|
||||
const timestamp = Date.UTC(year, month - 1, day);
|
||||
const date = new Date(timestamp);
|
||||
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||
? timestamp
|
||||
: Number.NaN;
|
||||
}
|
||||
|
||||
function todayDateOnlyMs() {
|
||||
const now = new Date();
|
||||
return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
function usageDateDistance(record, todayMs) {
|
||||
const checkinMs = dateOnlyMs(record.checkin);
|
||||
const checkoutMs = dateOnlyMs(record.checkout);
|
||||
if (!Number.isFinite(checkinMs)) return Number.MAX_SAFE_INTEGER;
|
||||
const startMs = Math.min(checkinMs, Number.isFinite(checkoutMs) ? checkoutMs : checkinMs);
|
||||
const endMs = Math.max(checkinMs, Number.isFinite(checkoutMs) ? checkoutMs : checkinMs);
|
||||
if (todayMs < startMs) return startMs - todayMs;
|
||||
if (todayMs > endMs) return todayMs - endMs;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function compareDateDescending(aValue, bValue) {
|
||||
const aMs = dateOnlyMs(aValue);
|
||||
const bMs = dateOnlyMs(bValue);
|
||||
if (Number.isFinite(aMs) && Number.isFinite(bMs)) return bMs - aMs;
|
||||
if (Number.isFinite(aMs)) return -1;
|
||||
if (Number.isFinite(bMs)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function usageRecordsInDisplayOrder(records) {
|
||||
const todayMs = todayDateOnlyMs();
|
||||
return [...records].sort((a, b) => {
|
||||
if (a.recordSource === "api" || b.recordSource === "api") {
|
||||
const createdDifference = String(b.enteredAt || "").localeCompare(String(a.enteredAt || ""));
|
||||
return createdDifference || String(b.id || "").localeCompare(String(a.id || ""));
|
||||
}
|
||||
const aIsManual = a.recordSource === "manual";
|
||||
const bIsManual = b.recordSource === "manual";
|
||||
|
||||
if (aIsManual !== bIsManual) return aIsManual ? -1 : 1;
|
||||
if (aIsManual) {
|
||||
const sequenceDifference = (b.entrySequence || 0) - (a.entrySequence || 0);
|
||||
return sequenceDifference || String(b.enteredAt || "").localeCompare(String(a.enteredAt || ""));
|
||||
}
|
||||
|
||||
return (a.importOrder ?? Number.MAX_SAFE_INTEGER) - (b.importOrder ?? Number.MAX_SAFE_INTEGER);
|
||||
const distanceDifference = usageDateDistance(a, todayMs) - usageDateDistance(b, todayMs);
|
||||
if (distanceDifference) return distanceDifference;
|
||||
const checkinDifference = compareDateDescending(a.checkin, b.checkin);
|
||||
if (checkinDifference) return checkinDifference;
|
||||
const checkoutDifference = compareDateDescending(a.checkout, b.checkout);
|
||||
if (checkoutDifference) return checkoutDifference;
|
||||
return String(b.id || "").localeCompare(String(a.id || ""));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,6 +297,8 @@ const I18N = {
|
||||
nextPage: "Next page",
|
||||
goToPage: ({ page }) => `Go to page ${formatNumber(page)}`,
|
||||
ownerPageStatus: ({ from, to, total, page, totalPages }) => `${formatNumber(from)}-${formatNumber(to)} of ${formatNumber(total)} · Page ${formatNumber(page)} of ${formatNumber(totalPages)}`,
|
||||
usagePaginationAria: "Usage history pages",
|
||||
usagePageStatus: ({ from, to, total, page, totalPages }) => `${formatNumber(from)}-${formatNumber(to)} of ${formatNumber(total)} records · Page ${formatNumber(page)} of ${formatNumber(totalPages)}`,
|
||||
recordsShown: ({ count }) => `${formatNumber(count)} record${count === 1 ? "" : "s"} shown`,
|
||||
viewAccount: ({ name }) => `View ${name} account`,
|
||||
openAccount: ({ name }) => `Open ${name} account`,
|
||||
@@ -483,6 +516,8 @@ const I18N = {
|
||||
nextPage: "下一页",
|
||||
goToPage: ({ page }) => `转到第 ${formatNumber(page)} 页`,
|
||||
ownerPageStatus: ({ from, to, total, page, totalPages }) => `第 ${formatNumber(page)} / ${formatNumber(totalPages)} 页 · 显示 ${formatNumber(from)}-${formatNumber(to)},共 ${formatNumber(total)} 个账户`,
|
||||
usagePaginationAria: "使用记录分页",
|
||||
usagePageStatus: ({ from, to, total, page, totalPages }) => `第 ${formatNumber(page)} / ${formatNumber(totalPages)} 页 · 显示 ${formatNumber(from)}-${formatNumber(to)},共 ${formatNumber(total)} 条记录`,
|
||||
recordsShown: ({ count }) => `显示 ${formatNumber(count)} 条记录`,
|
||||
viewAccount: ({ name }) => `查看 ${name} 的账户`,
|
||||
openAccount: ({ name }) => `打开 ${name} 的账户`,
|
||||
@@ -700,6 +735,8 @@ const I18N = {
|
||||
nextPage: "หน้าถัดไป",
|
||||
goToPage: ({ page }) => `ไปหน้าที่ ${formatNumber(page)}`,
|
||||
ownerPageStatus: ({ from, to, total, page, totalPages }) => `หน้า ${formatNumber(page)} จาก ${formatNumber(totalPages)} · แสดง ${formatNumber(from)}-${formatNumber(to)} จาก ${formatNumber(total)} บัญชี`,
|
||||
usagePaginationAria: "หน้าประวัติการใช้สิทธิ์",
|
||||
usagePageStatus: ({ from, to, total, page, totalPages }) => `หน้า ${formatNumber(page)} จาก ${formatNumber(totalPages)} · แสดง ${formatNumber(from)}-${formatNumber(to)} จาก ${formatNumber(total)} รายการ`,
|
||||
recordsShown: ({ count }) => `แสดง ${formatNumber(count)} รายการ`,
|
||||
viewAccount: ({ name }) => `ดูบัญชีของ ${name}`,
|
||||
openAccount: ({ name }) => `เปิดบัญชีของ ${name}`,
|
||||
@@ -1386,11 +1423,17 @@ function renderStays() {
|
||||
const haystack = `${record.confirmation} ${owner.name} ${owner.room} ${owner.member} ${owner.unit} ${record.usedType} ${record.remark || ""}`.toLowerCase();
|
||||
return haystack.includes(query) && (!type || record.usedType === type);
|
||||
}));
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / USAGE_PAGE_SIZE));
|
||||
usagePage = Math.min(Math.max(usagePage, 1), totalPages);
|
||||
const pageOffset = (usagePage - 1) * USAGE_PAGE_SIZE;
|
||||
const visibleStays = filtered.slice(pageOffset, pageOffset + USAGE_PAGE_SIZE);
|
||||
const pageFrom = filtered.length ? pageOffset + 1 : 0;
|
||||
const pageTo = Math.min(pageOffset + USAGE_PAGE_SIZE, filtered.length);
|
||||
|
||||
$("#stayCount").textContent = t("recordsShown", { count: filtered.length });
|
||||
$("#stayCount").textContent = t("recordsShown", { count: visibleStays.length });
|
||||
$("#usageTabCount").textContent = formatNumber(stayRecords.length);
|
||||
const databaseEmpty = IS_API_MODE && connectionState === "ready" && stayRecords.length === 0;
|
||||
$("#stayTableBody").innerHTML = filtered.length ? filtered.map(record => {
|
||||
$("#stayTableBody").innerHTML = visibleStays.length ? visibleStays.map(record => {
|
||||
const owner = ownerFor(record.ownerId);
|
||||
return `<tr class="clickable-row" data-owner-id="${owner.id}" tabindex="0">
|
||||
<td data-label="${t("confirmationNo")}"><strong class="mono">${record.confirmation}</strong></td>
|
||||
@@ -1409,6 +1452,30 @@ function renderStays() {
|
||||
</tr>`;
|
||||
}).join("") : `<tr class="empty-table-row"><td colspan="10"><div class="table-empty"><strong>${t(databaseEmpty ? "noDatabaseUsage" : "noUsageRecordsFound")}</strong><span>${t(databaseEmpty ? "noDatabaseUsageDescription" : "tryUsageFilter")}</span></div></td></tr>`;
|
||||
|
||||
const pagination = $("#stayPagination");
|
||||
if (pagination) {
|
||||
pagination.hidden = totalPages <= 1;
|
||||
$("#stayPageStatus").textContent = filtered.length
|
||||
? t("usagePageStatus", { from: pageFrom, to: pageTo, total: filtered.length, page: usagePage, totalPages })
|
||||
: "";
|
||||
const pageNumbers = $("#stayPageNumbers");
|
||||
if (pageNumbers) {
|
||||
pageNumbers.hidden = totalPages <= 1;
|
||||
pageNumbers.innerHTML = totalPages > 1
|
||||
? Array.from({ length: totalPages }, (_, index) => {
|
||||
const page = index + 1;
|
||||
const current = page === usagePage;
|
||||
return `<button class="pagination-number" type="button" data-usage-page="${page}" aria-label="${escapeHtml(t("goToPage", { page }))}"${current ? ' aria-current="page"' : ""}>${formatNumber(page)}</button>`;
|
||||
}).join("")
|
||||
: "";
|
||||
$$("#stayPageNumbers [data-usage-page]").forEach(button => button.addEventListener("click", () => {
|
||||
usagePage = Number(button.dataset.usagePage);
|
||||
renderStays();
|
||||
}));
|
||||
}
|
||||
}
|
||||
$("#stayTableWrap").scrollTop = 0;
|
||||
|
||||
$$("#stayTableBody tr[data-owner-id]").forEach(row => {
|
||||
bindTableRowActivation(row, () => openOwnerDrawer(row.dataset.ownerId));
|
||||
});
|
||||
@@ -2081,8 +2148,14 @@ function bindEvents() {
|
||||
ownerPage = 1;
|
||||
renderOwners();
|
||||
});
|
||||
$("#staySearch").addEventListener("input", renderStays);
|
||||
$("#stayTypeFilter").addEventListener("change", renderStays);
|
||||
$("#staySearch").addEventListener("input", () => {
|
||||
usagePage = 1;
|
||||
renderStays();
|
||||
});
|
||||
$("#stayTypeFilter").addEventListener("change", () => {
|
||||
usagePage = 1;
|
||||
renderStays();
|
||||
});
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") {
|
||||
if ($("#confirmDialog").classList.contains("open")) closeConfirmDialog(false);
|
||||
|
||||
@@ -253,12 +253,16 @@
|
||||
<select id="stayTypeFilter" aria-label="Filter by used room type" data-i18n-aria-label="usedTypeFilterAria"><option value="" data-i18n="allUsedRoomTypes">All used room types</option><option>RM1</option><option>RM2</option><option>RM3</option><option>RM4</option><option>SU1</option><option>SU2</option><option>SU3</option><option>SU6</option><option>UG1</option><option>UG2</option><option>AC2</option></select>
|
||||
<span class="toolbar-count" id="stayCount">0 records shown</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<div class="table-wrap" id="stayTableWrap">
|
||||
<table class="usage-table">
|
||||
<thead><tr><th data-i18n="confirmationNo">Confirmation No.</th><th data-i18n="ownerRoom">Owner / room</th><th data-i18n="checkIn">Check-in</th><th data-i18n="checkOut">Check-out</th><th data-i18n="night">Night</th><th data-i18n="use">Use</th><th data-i18n="balance">Balance</th><th data-i18n="usedRoomType">Used Room Type</th><th data-i18n="remark">Remark</th><th><span class="sr-only" data-i18n="actions">Actions</span></th></tr></thead>
|
||||
<tbody id="stayTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav class="table-pagination" id="stayPagination" aria-label="Usage history pages" data-i18n-aria-label="usagePaginationAria" hidden>
|
||||
<span class="pagination-status" id="stayPageStatus" role="status" aria-live="polite"></span>
|
||||
<div class="pagination-pages" id="stayPageNumbers" role="group" aria-label="Page numbers" data-i18n-aria-label="pageNumbersAria"></div>
|
||||
</nav>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -34,7 +34,7 @@ function placeholderNames(value) {
|
||||
test("all three locale dictionaries have identical keys", () => {
|
||||
assert.deepEqual(locales, ["en", "zh", "th"]);
|
||||
const expected = Object.keys(dictionaries.en).sort();
|
||||
assert.equal(expected.length, 215);
|
||||
assert.equal(expected.length, 217);
|
||||
for (const locale of locales) {
|
||||
assert.deepEqual(Object.keys(dictionaries[locale]).sort(), expected, `${locale} key parity`);
|
||||
}
|
||||
|
||||
41
tests/usage-history.test.mjs
Normal file
41
tests/usage-history.test.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const appSource = await readFile(new URL("../app.js", import.meta.url), "utf8");
|
||||
const helperStart = appSource.indexOf("function dateOnlyMs");
|
||||
const helperEnd = appSource.indexOf("\n\nconst I18N = ", helperStart);
|
||||
assert.notEqual(helperStart, -1, "usage date helpers should exist");
|
||||
assert.notEqual(helperEnd, -1, "usage date helpers should end before translations");
|
||||
|
||||
const helperContext = {};
|
||||
vm.runInNewContext(`${appSource.slice(helperStart, helperEnd)}\nglobalThis.usageRecordsInDisplayOrder = usageRecordsInDisplayOrder;`, helperContext);
|
||||
|
||||
function localIsoOffset(offsetDays) {
|
||||
const date = new Date();
|
||||
date.setHours(12, 0, 0, 0);
|
||||
date.setDate(date.getDate() + offsetDays);
|
||||
return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-");
|
||||
}
|
||||
|
||||
test("usage history orders records by distance from today's stay interval", () => {
|
||||
const records = [
|
||||
{ id: "missing", checkin: "not-a-date", checkout: "not-a-date" },
|
||||
{ id: "future-far", checkin: localIsoOffset(10), checkout: localIsoOffset(12) },
|
||||
{ id: "past-near", checkin: localIsoOffset(-5), checkout: localIsoOffset(-2) },
|
||||
{ id: "active", checkin: localIsoOffset(-1), checkout: localIsoOffset(1) },
|
||||
{ id: "future-near", checkin: localIsoOffset(1), checkout: localIsoOffset(3) }
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(helperContext.usageRecordsInDisplayOrder(records), record => record.id),
|
||||
["active", "future-near", "past-near", "future-far", "missing"]
|
||||
);
|
||||
});
|
||||
|
||||
test("usage history keeps the requested page size and direct pagination surface", () => {
|
||||
assert.match(appSource, /const USAGE_PAGE_SIZE = 30;/);
|
||||
assert.match(appSource, /#stayPageNumbers/);
|
||||
assert.match(appSource, /data-usage-page/);
|
||||
});
|
||||
Reference in New Issue
Block a user