diff --git a/app.js b/app.js index 8884d23..8d0b744 100644 --- a/app.js +++ b/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 ` ${record.confirmation} @@ -1409,6 +1452,30 @@ function renderStays() { `; }).join("") : `
${t(databaseEmpty ? "noDatabaseUsage" : "noUsageRecordsFound")}${t(databaseEmpty ? "noDatabaseUsageDescription" : "tryUsageFilter")}
`; + 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 ``; + }).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); diff --git a/index.html b/index.html index a1b0f65..b5af7cb 100644 --- a/index.html +++ b/index.html @@ -253,12 +253,16 @@ 0 records shown -
+
Confirmation No.Owner / roomCheck-inCheck-outNightUseBalanceUsed Room TypeRemarkActions
+ diff --git a/tests/i18n.test.mjs b/tests/i18n.test.mjs index 4720872..430fe84 100644 --- a/tests/i18n.test.mjs +++ b/tests/i18n.test.mjs @@ -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`); } diff --git a/tests/usage-history.test.mjs b/tests/usage-history.test.mjs new file mode 100644 index 0000000..96a1f97 --- /dev/null +++ b/tests/usage-history.test.mjs @@ -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/); +});