Add daily report calendar to the header

This commit is contained in:
Wyndham ARR committed 2026-10-09 13:20:13 +08:00
1 parent 2a54838d59
commit 4356160cc9
11 files changed
+735 -28

No files matched your search

@@ -0,0 +1,66 @@
# Task: 顶部报表日历与有效日报日期展示
## Identity
- Task ID: 20261009-report-calendar-d19a
- Mode: Feature
- Branch: codex/20261009-report-calendar-d19a-report-calendar
- Worktree: /Users/chillishark/.codex/worktrees/arr-report-calendar
- Base commit: 2a54838d59466139c41a9c7fe79593242d6da982
- Owner: codex
- Status: Ready for Integration
## Scope
- Replace the daily header processing-duration card with an always-visible report calendar after ARRIVAL DATE and NO. OF ROOM. Default to current business month, distinguish effective generated/ungenerated dates, support month browsing and selecting a date without acquiring/processing data.
## Intent And Constraints
- Preserve current blue/white product style, shared XML/OHIP business rules, valid daily-version identity, authenticated read-only APIs and original tasks/data. Only an actual current effective daily report counts as generated; historical/deleted/pending-only records do not. Pending replacement retains generated status if a current daily exists. Month/date viewing never submits acquisition. Preserve unsaved review decisions and concurrency guards. No Oracle calls or actual report mutation. Safely activate only with no active task; prior repository publication authorization persists.
## Outcome
- Header now presents automatic download, XML upload, ARRIVAL DATE, NO. OF ROOM and Reports in that order. The header duration card is removed; daily history duration remains available.
- The calendar defaults to the Bangkok business month, browses months, shows full-month effective report coverage and selects a date for read-only overview/list viewing. Current accepted/succeeded, non-retired reports count as generated even with zero rooms or a pending replacement; pending-only, historical and deleted-only versions do not. Loading/error states remain unknown, with retry.
- Three locales, visible status labels, keyboard month/date navigation, focus/selected/today states and responsive layouts are implemented. Independent calendar loading does not depend on daily-list pagination or add a polling timer.
- Date selection is protected against unsaved prices, active submissions/reviews, stale month responses and same-date environment changes. XML upload and price/field review completion restore calendar availability immediately. Independent review found and verified fixes for two concurrency/busy-state defects before activation.
- Local 8875 now runs this retained worktree. Activation changed only the launcher/project paths, with a cold backup at /Users/chillishark/Library/Application Support/ARR2.0/report-calendar-backup-20261009-051154. Schema remains 008-022, with no business-data mutation or Oracle query. Repository publication is authorized to arr0918/main; the final push receipt is stored privately after publication.
## Verification
- 132 JavaScript UI tests passed, including 27 new calendar cases for effective coverage, empty/leap months, incomplete/error/late responses, safe selection, unsaved prices, environment changes, refresh after generation/deletion, XML upload and review completion/cancellation/failure.
- 15 relevant daily visual/price-review static checks passed; 4 readonly preview tests passed. JavaScript syntax and git diff --check passed.
- Independent readonly review closed both identified defects and reconfirmed GET-only selection, current-report authority, and unchanged XML/OHIP/monthly processing rules.
- Browser proof: isolated readonly synthetic fixtures confirmed default October, September current/pending/history differences, calendar/list date synchronization, English/Thai Gregorian labels, keyboard crossing October31 to November1 and a complete empty month. Actual CSS widths 375, 767 (near768), 1024 and1440 had no page horizontal overflow; mobile day targets were approximately44px.
- Actual 8875 proof: October7 generated, September16/17 generated, September13/15 ungenerated. Selecting September17 shows 2026/09/17 and43 rooms and switches the daily list to September, while both existing field-review dates remain. Saved live-report-calendar.png privately under report-calendar-validation-20261009; no report download, acquisition, review submission or deletion was performed.
- Before/after private comparison preserved all27 table fingerprints, 3 current report pointers, 4896 source files, monthly workbook/API records and queue statuses. Pending field-review dates remain September15 and13; retirement audit unchanged.
- An additional existing history/task-log/BI static group has 3 stale source-string assertions already inconsistent with base2a54838 (history direct expression, old renderJobs signature, single openTaskLog call). They are not introduced by this feature; files remain unchanged. This is not a blanket full-suite success claim.
## Follow-ups
- No product work remains. Keep this worktree because the local service runs from it. Canonical memory reconciliation remains a separate serialized integration task.
## Promotion Candidates
- Target: current-state.md and portal architecture/domain memory. Proposal: record effective-report month coverage, header order without duration, read-only calendar selection, and separate viewed date/review context. Evidence: explicit user instructions, 132 JS/15 UI checks and actual8875 preservation/browser proof. Future impact: prevents historical or pending tasks from being shown as generated, and prevents future view controls from submitting acquisition. No semantic conflict with accepted processing rules; user approval of this product behavior already supplied. Feature mode records this candidate without canonical writes.
## Concurrent Task Gate
- Passed: task_context.py start exited0 and isolated task20261009-report-calendar-d19a, feature/codex, branchcodex/20261009-report-calendar-d19a-report-calendar, managed worktree/Users/chillishark/.codex/worktrees/arr-report-calendar, base2a54838d59466139c41a9c7fe79593242d6da982. status confirms ownership and peer scopes. production-review and daily-lifecycle tasks Ready for Integration, inspected read-only; current8875 runs daily-lifecycle checkout. No write overlap; dirty primary checkout untouched.
## Project Context Loaded
- Read: entry/planning gate, own task record, memory-index, positioning, current-state, decision-index, system-overview, ADR007, domain/success-criteria, evidence/commitments/stale context and both peer task Scope/Constraints/Promotion Candidates.
- Project goal: finance operator XML/OHIP inputs processed by shared deterministic rules into effective daily facts and automatically updated monthly workbooks.
- Current focus: report coverage visible as a compact header month calendar and consistent selected-date overview.
- Constraints/decisions: effective current daily pointer is authoritative; source/history/audit remain retained. Only successful new version replaces current; no changes to business scope/price/review/Finance/monthly processing. View actions remain GET-only.
- Evidence/conflicts: canonical September state is stale versus direct user instructions and completed October9 lifecycle task (hotel57106, processor4.4.0, schema022, multiple agents allowed). This task extends that completed base and does not modify canonical memory. User explicitly approved calendar direction and requested continuation.
- Modules: static index/styles/i18n/app, existing authenticated GET /api/daily-reports month list and exact-day overview, synthetic JS/readonly preview verification, private8875 launcher path.
- Unknowns: desktop card density, small-screen layout and asynchronous month/date refresh require implementation/browser proof; no business decision blocks work.
- Gate: Passed.
## Plan
1. Implement header order and accessible calendar with independent month state, whole-month effective-day reads and safe date selection.
2. Verify empty/partial months, pending/replaced/deleted days, month/year transitions, late responses and no automatic acquisition; check desktop and narrow layouts.
3. Safely update local code path while preserving current schema/data, capture browser proof, commit and push to approved repository.
+5 -1
View File
@@ -33,7 +33,11 @@
日报列表按业务日期展示,一天只展示一行。接口取数和XML上传遵循相同的覆盖规则:新版本成功生成并入库后,才替换该日有效日报并更新月报;新版本尚待核对或失败时,原有效日报仍可下载并继续计入月报。
点击列表中的日期,可查看该日概览;上方日期、房数和耗时始终对应当前查看日期。待处理新版本会单独提示,尚无有效日报时显示空值,不借用其他日期的成功结果。
点击列表中的日期,可查看该日概览;上方日期与房数始终对应当前查看日期。待处理新版本会单独提示,尚无有效日报时显示空值,不借用其他日期的成功结果。
顶部概览提供“自动下载 ARR、上传 ARR.XML、ARRIVAL DATE、NO. OF ROOM、报表”五个区域,顶部不再展示处理耗时。报表日历默认显示当前业务月份,支持切换月份;每一天按是否存在当前有效日报标为“已生成”或“未生成”。历史版本、已删除版本和仅待核对的任务不算已生成;已有有效日报、另有待核对新版本时,仍标为已生成。读取中或读取失败会单独显示,不把未知状态当作未生成。
点击日历日期只切换查看日期和日报列表月份,不会获取Oracle数据、生成报表或提交人工核对值。需要下载新数据时,仍须在自动下载入口主动提交;未保存的人工价格继续受切换保护。日历月份与列表分页独立,显示整月状态;生成或删除后的刷新会更新标记。
“历史记录”保留同一天的旧版本和未完成版本。删除前页面会说明影响:
+2
View File
@@ -2,6 +2,8 @@
2026-10-09:日报按日期展示一个有效版本,支持查看历史、删除及月报同步;概览跟随所选日期。发布需要数据库022及配套月报worker,详见[交付说明](../deploy/OHIP_RELEASE_HANDOVER.md#日报版本管理升级2026-10-09)。
顶部新增“报表”月历,按当前有效日报显示已生成/未生成日期,可切换月份和选择日期查看;顶部处理耗时已移除。日历只读取ARR已保存状态,不查询Oracle或自动创建报表。
2026-09-18:[直接数据入口](DIRECT_DATA_ENTRY.md)已接通日期按钮、日报、复核和月报;独立本机模拟8875,未查询实际Oracle。
2026-09-17:[本机接口模拟全流程及按钮联调](LOCAL_API_SIMULATION.md)已在独立8874验证;[原文件重放](LOCAL_XML_REPLAY.md)8873保留。两个入口均明确未连接Oracle,不改变默认运行配置。
+227 -5
View File
@@ -59,6 +59,16 @@
dailyOverview: null,
dailyOverviewTimer: null,
dailyOverviewRequest: 0,
reportDateSelectionRequest: 0,
reportCalendarMonth: "",
reportCalendarDataMonth: "",
reportCalendarContext: "",
reportCalendarRows: [],
reportCalendarLoading: false,
reportCalendarError: false,
reportCalendarRequest: 0,
reportCalendarFocusDate: "",
reportCalendarInitialized: false,
dailyHistoryDate: "",
dailyHistoryItems: [],
dailyHistoryTotal: 0,
@@ -647,6 +657,7 @@
}
const storageKey = `arr:last-download:v2:${encodeURIComponent(config.context_id)}:${encodeURIComponent(state.arrDownloadUsername)}`;
if (state.arrDownloadStorageKey !== storageKey) {
state.reportDateSelectionRequest += 1;
const contextChanged = state.arrDownloadContextId && state.arrDownloadContextId !== config.context_id;
state.arrDownloadStorageKey = storageKey;
state.arrDownloadContextId = config.context_id;
@@ -669,6 +680,12 @@
state.dailyHistoryDate = "";
renderJobs();
renderDailyHistory();
state.reportCalendarRequest += 1;
state.reportCalendarRows = [];
state.reportCalendarDataMonth = "";
state.reportCalendarError = false;
state.reportCalendarLoading = true;
loadReportCalendar();
}
state.manualReviewMode = "";
state.dailyPriceReview = null;
@@ -850,6 +867,7 @@
}
function renderARRDataReview() {
renderReportCalendar();
const panel = $("#arr-data-review-panel");
const review = state.arrDataReview;
renderManualReviewWorkspace();
@@ -1319,6 +1337,187 @@
updateCompanyReportControls();
}
function reportCalendarSelectionLocked() {
return Boolean(arrDownloadActive() || state.arrDownloadBusy || state.arrDownloadLoading
|| state.arrDataReviewLoading || state.arrDataReviewMutating || state.arrDataReviewFinalizing
|| state.dailyPriceReviewLoading || state.dailyPriceReviewMutating
|| state.uploadInFlight || state.dailyDeleting);
}
function currentReportGenerated(row) {
const job = row?.current_job;
return Boolean(job && job.is_current === true && !job.is_retired
&& ["succeeded", "accepted"].includes(job.status));
}
function reportCalendarReady() {
return !state.reportCalendarLoading && !state.reportCalendarError
&& state.reportCalendarDataMonth === state.reportCalendarMonth
&& state.reportCalendarContext === state.arrDownloadContextId;
}
function renderReportCalendar(focusDay = false) {
const month = state.reportCalendarMonth || localMonth();
state.reportCalendarMonth = month;
const first = new Date(`${month}-01T12:00:00Z`);
const dayCount = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
const leading = (first.getUTCDay() + 6) % 7;
const selected = state.dailyOverviewDate;
const today = localDate();
if (!validARRDate(state.reportCalendarFocusDate) || state.reportCalendarFocusDate.slice(0, 7) !== month) {
state.reportCalendarFocusDate = selected?.slice(0, 7) === month ? selected
: today.slice(0, 7) === month ? today : `${month}-01`;
}
const activeDay = document.activeElement?.closest?.("[data-report-calendar-day]");
const restoreDayFocus = focusDay || Boolean(activeDay);
const ready = reportCalendarReady();
const locked = reportCalendarSelectionLocked();
const format = (options) => new Intl.DateTimeFormat(I18N?.locale || "zh-CN", { timeZone: "UTC", calendar: "gregory", ...options });
const dateLabel = format({ year: "numeric", month: "long", day: "numeric", weekday: "long" });
const weekday = format({ weekday: "short" });
const weekdays = [];
for (let index = 0; index < 7; index += 1) {
const monday = new Date("2026-09-07T12:00:00Z");
monday.setUTCDate(monday.getUTCDate() + index);
weekdays.push(`<span aria-hidden="true">${escapeHtml(weekday.format(monday))}</span>`);
}
const rows = new Map(state.reportCalendarRows.map((row) => [row.business_date, row]));
const days = Array.from({ length: leading }, () => '<span class="report-calendar-spacer" aria-hidden="true"></span>');
let generated = 0;
for (let number = 1; number <= dayCount; number += 1) {
const value = `${month}-${String(number).padStart(2, "0")}`;
const exists = ready && currentReportGenerated(rows.get(value));
if (exists) generated += 1;
const status = !ready ? state.reportCalendarLoading ? "loading_day" : "unknown" : exists ? "generated" : "not_generated";
const statusLabel = I18N.t(`report_calendar.${status}`);
const shortLabel = I18N.t(`report_calendar.day_${status}`);
const symbol = exists ? "✓" : ready ? "—" : "·";
const selectedDay = value === selected;
days.push(`<button type="button" class="report-calendar-day ${exists ? "is-generated" : ready ? "is-ungenerated" : "is-unknown"}${value === today ? " is-today" : ""}" data-report-calendar-day="${value}" aria-label="${escapeHtml(`${dateLabel.format(new Date(`${value}T12:00:00Z`))} · ${statusLabel}`)}" aria-pressed="${selectedDay}" ${value === today ? 'aria-current="date"' : ""} tabindex="${value === state.reportCalendarFocusDate ? 0 : -1}" ${locked ? "disabled" : ""}><span class="report-calendar-day-number">${number}</span><span class="report-calendar-day-state"><span aria-hidden="true">${symbol}</span><span>${escapeHtml(shortLabel)}</span></span></button>`);
}
$("#report-calendar-month").textContent = I18N.formatMonth(month);
$("#report-calendar-previous").disabled = month === "2020-01";
$("#report-calendar-next").disabled = month === "2099-12";
$("#report-calendar-previous").setAttribute("aria-label", I18N.t("history.previous_month"));
$("#report-calendar-next").setAttribute("aria-label", I18N.t("history.next_month"));
$("#report-calendar-current").textContent = I18N.t("history.current_month");
$("#report-calendar-weekdays").innerHTML = weekdays.join("");
$("#report-calendar-days").innerHTML = days.join("");
$("#report-calendar").setAttribute("aria-busy", String(state.reportCalendarLoading));
$("#report-calendar-status").textContent = I18N.t(state.reportCalendarLoading ? "report_calendar.loading"
: state.reportCalendarError ? "report_calendar.failed" : ready ? "report_calendar.coverage" : "report_calendar.loading",
{ count: formatInteger(generated), total: formatInteger(dayCount) });
$("#report-calendar-help").textContent = I18N.t(locked ? "report_calendar.locked" : "report_calendar.help");
$("#report-calendar-retry").hidden = !state.reportCalendarError;
$("#report-calendar-legend-generated").textContent = I18N.t("report_calendar.generated");
$("#report-calendar-legend-empty").textContent = I18N.t("report_calendar.not_generated");
if (restoreDayFocus) $(`[data-report-calendar-day="${state.reportCalendarFocusDate}"]`, $("#report-calendar-days"))?.focus();
}
async function loadReportCalendar() {
if (!state.reportCalendarInitialized) return;
const month = state.reportCalendarMonth || localMonth();
const context = state.arrDownloadContextId;
const sequence = ++state.reportCalendarRequest;
state.reportCalendarLoading = true;
state.reportCalendarError = false;
renderReportCalendar();
const isCurrent = () => state.reportCalendarMonth === month
&& state.arrDownloadContextId === context && state.reportCalendarRequest === sequence;
try {
const envelope = await api(`/api/daily-reports?month=${encodeURIComponent(month)}&limit=200&offset=0`, { returnEnvelope: true });
if (!isCurrent()) return;
const rows = envelope?.data;
const total = Number(envelope?.pagination?.total);
// Only a complete successful month snapshot can establish an ungenerated day.
if (!Array.isArray(rows) || !Number.isInteger(total) || total !== rows.length || total > 31
|| rows.some((row) => !validARRDate(row?.business_date) || row.business_date.slice(0, 7) !== month
|| !Object.prototype.hasOwnProperty.call(row, "current_job"))
|| new Set(rows.map((row) => row.business_date)).size !== rows.length) throw new Error("Incomplete calendar snapshot");
state.reportCalendarRows = rows;
state.reportCalendarDataMonth = month;
state.reportCalendarContext = context;
} catch (_) {
if (!isCurrent()) return;
state.reportCalendarError = true;
state.reportCalendarRows = [];
state.reportCalendarDataMonth = "";
} finally {
if (isCurrent()) {
state.reportCalendarLoading = false;
renderReportCalendar();
}
}
}
async function changeReportCalendarMonth(month, focusDate = "") {
if (!validMonth(month) || month < "2020-01" || month > "2099-12") return;
state.reportCalendarMonth = month;
state.reportCalendarFocusDate = focusDate;
state.reportCalendarRequest += 1;
state.reportCalendarRows = [];
state.reportCalendarDataMonth = "";
state.reportCalendarError = false;
state.reportCalendarLoading = true;
renderReportCalendar(Boolean(focusDate));
await loadReportCalendar();
}
async function selectReportCalendarDate(date) {
if (!validARRDate(date) || date.slice(0, 7) !== state.reportCalendarMonth || reportCalendarSelectionLocked()) return false;
const context = state.arrDownloadContextId;
const month = state.reportCalendarMonth;
const pending = selectDailyReportDate(date, { syncHistory: true });
const sequence = state.reportDateSelectionRequest;
const selected = await pending;
if (selected && context === state.arrDownloadContextId && sequence === state.reportDateSelectionRequest
&& month === state.reportCalendarMonth && state.dailyOverviewDate === date) {
state.reportCalendarFocusDate = date;
renderReportCalendar(true);
}
return selected;
}
async function moveReportCalendarFocus(key, date) {
if (!validARRDate(date) || reportCalendarSelectionLocked()) return;
const day = new Date(`${date}T12:00:00Z`);
if (["PageUp", "PageDown"].includes(key)) {
const month = shiftMonth(date.slice(0, 7), key === "PageUp" ? -1 : 1);
const last = new Date(`${month}-01T12:00:00Z`);
last.setUTCMonth(last.getUTCMonth() + 1);
last.setUTCDate(0);
await changeReportCalendarMonth(month, `${month}-${String(Math.min(day.getUTCDate(), last.getUTCDate())).padStart(2, "0")}`);
return;
}
const weekday = (day.getUTCDay() + 6) % 7;
const deltas = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7, Home: -weekday, End: 6 - weekday };
if (!Object.prototype.hasOwnProperty.call(deltas, key)) return;
day.setUTCDate(day.getUTCDate() + deltas[key]);
const value = day.toISOString().slice(0, 10);
if (value.slice(0, 7) !== state.reportCalendarMonth) await changeReportCalendarMonth(value.slice(0, 7), value);
else {
state.reportCalendarFocusDate = value;
renderReportCalendar(true);
}
}
function bindReportCalendar() {
$("#report-calendar-previous").addEventListener("click", () => changeReportCalendarMonth(shiftMonth(state.reportCalendarMonth, -1)));
$("#report-calendar-next").addEventListener("click", () => changeReportCalendarMonth(shiftMonth(state.reportCalendarMonth, 1)));
$("#report-calendar-current").addEventListener("click", () => changeReportCalendarMonth(localMonth()));
$("#report-calendar-retry").addEventListener("click", loadReportCalendar);
$("#report-calendar-days").addEventListener("click", (event) => {
const day = event.target.closest("[data-report-calendar-day]");
if (day) selectReportCalendarDate(day.dataset.reportCalendarDay);
});
$("#report-calendar-days").addEventListener("keydown", (event) => {
const day = event.target.closest("[data-report-calendar-day]");
if (!day || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(event.key)) return;
event.preventDefault();
moveReportCalendarFocus(event.key, day.dataset.reportCalendarDay);
});
}
function dailyJobSource(job) {
return job?.filename === "ARR.json" ? I18N.t("arr_download.source_label") : job?.filename || "—";
}
@@ -1365,15 +1564,15 @@
$("#metric-current-version").hidden = !current || (!pending && !failed);
$("#metric-current-version").textContent = I18N.t("daily.current_preserved");
const showResult = Boolean(current && !pending);
$("#metric-duration").textContent = showResult ? duration(current) : "—";
$("#metric-rooms").textContent = showResult ? formatInteger(current.no_of_rooms) : "—";
$("#metric-duration-note").textContent = I18N.t(showResult ? "metric.duration_from_submit" : "daily.after_generation");
$("#metric-rooms-note").textContent = I18N.t(showResult ? "metric.rooms" : "daily.after_generation");
renderReportCalendar();
}
function syncDailyOverviewDate(date) {
if (!validARRDate(date)) {
state.dailyOverviewDate = "";
state.reportDateSelectionRequest += 1;
state.dailyOverview = null;
state.dailyOverviewRequest += 1;
window.clearTimeout(state.dailyOverviewTimer);
@@ -1381,6 +1580,7 @@
return;
}
if (state.dailyOverviewDate !== date) {
state.reportDateSelectionRequest += 1;
state.dailyOverviewDate = date;
state.dailyOverview = null;
state.dailyOverviewRequest += 1;
@@ -1406,15 +1606,28 @@
} catch (_) { /* Do not substitute a different date's result when this day cannot be read. */ }
}
async function selectDailyReportDate(date) {
if (!validARRDate(date) || state.dailyDeleting || state.arrDownloadBusy || dailyPriceReviewHasUnsavedChanges()) {
async function selectDailyReportDate(date, { syncHistory = false } = {}) {
if (!validARRDate(date) || reportCalendarSelectionLocked() || dailyPriceReviewHasUnsavedChanges()) {
if (dailyPriceReviewHasUnsavedChanges()) showToast(I18N.t("daily_review.save_before_switch"), true);
return;
return false;
}
$("#arr-download-date").value = date;
state.arrDownloadDateEdited = true;
renderARRDownload();
const context = state.arrDownloadContextId;
const sequence = ++state.reportDateSelectionRequest;
const isCurrent = () => state.dailyOverviewDate === date && state.arrDownloadContextId === context
&& state.reportDateSelectionRequest === sequence;
if (!dailyOverviewRow()) await loadDailyOverview(date);
if (!isCurrent()) return false;
if (syncHistory && state.jobsMonth !== date.slice(0, 7)) {
state.jobsMonth = date.slice(0, 7);
state.jobsOffset = 0;
renderHistoryMonthControl("jobs");
await loadJobs(false, false);
if (!isCurrent()) return false;
}
return isCurrent();
}
function renderJobs() {
@@ -1676,6 +1889,7 @@
}
function renderDailyPriceReview(review = state.dailyPriceReview) {
renderReportCalendar();
const panel = $("#daily-price-review-panel");
const body = $("#daily-price-review-body");
const empty = $("#daily-price-review-empty");
@@ -2261,6 +2475,7 @@
);
setTraceLiveState(I18N?.t("task.live_unselected") || "未选择任务");
}
await loadReportCalendar();
} catch (error) {
if (!isCurrent()) return;
$("#jobs-body").innerHTML = `<tr><td class="empty-cell" colspan="7">${escapeHtml(I18N?.t("daily.unavailable") || "日报记录暂时无法读取")}</td></tr>`;
@@ -2633,6 +2848,7 @@
showToast(error.message, true);
} finally {
state.uploadInFlight = false;
renderReportCalendar();
fileInput.disabled = !state.health.processing_ready;
dropzone.classList.toggle("is-disabled", !state.health.processing_ready);
button.removeAttribute("aria-busy");
@@ -3638,6 +3854,7 @@
renderARRDownload();
renderARRDataReview();
renderARRCalendar();
renderReportCalendar();
renderJobs(state.jobs);
renderDailyHistory();
if (state.dailyDeleteRequest) {
@@ -3724,6 +3941,7 @@
function bindEvents() {
bindHistoryMonthControls();
bindARRCalendar();
bindReportCalendar();
$$("[data-tab]").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.tab)));
$("#xml-file").addEventListener("change", (event) => {
if (state.uploadInFlight) return;
@@ -4016,6 +4234,8 @@
async function boot() {
$("#arr-download-date").value = defaultARRDate();
$("#company-report-month").value = localMonth();
state.reportCalendarMonth = localMonth();
renderReportCalendar();
populateMonths([]);
bindEvents();
activateTab(location.hash.slice(1) || "daily", false);
@@ -4036,6 +4256,8 @@
loadCompanyDraft(),
loadCompanyReportHistory(location.hash.slice(1) === "usage"),
]);
state.reportCalendarInitialized = true;
await loadReportCalendar();
}
boot();
+18
View File
@@ -254,6 +254,24 @@
"metric.duration": ["处理耗时", "Processing time", "เวลาประมวลผล"],
"metric.duration_from_submit": ["从提交至完成", "From submission to completion", "ตั้งแต่ส่งจนเสร็จ"],
"metric.rooms": ["保留记录的售出房数", "Sold rooms in retained records", "จำนวนห้องที่ขายในรายการที่เก็บไว้"],
"report_calendar.title": ["报表", "Reports", "รายงาน"],
"report_calendar.widget_aria": ["报表日历", "Report calendar", "ปฏิทินรายงาน"],
"report_calendar.generated": ["已生成", "Generated", "สร้างแล้ว"],
"report_calendar.not_generated": ["未生成", "Not generated", "ยังไม่สร้าง"],
"report_calendar.loading_day": ["读取中", "Loading", "กำลังอ่าน"],
"report_calendar.unknown": ["未确认", "Not confirmed", "ยังไม่ยืนยัน"],
"report_calendar.day_generated": ["已生成", "Ready", "มีแล้ว"],
"report_calendar.day_not_generated": ["未生成", "None", "ยังไม่มี"],
"report_calendar.day_loading_day": ["读取中", "Loading", "กำลังอ่าน"],
"report_calendar.day_unknown": ["未确认", "Unknown", "ไม่ทราบ"],
"report_calendar.loading": ["正在读取本月报表状态…", "Loading report status for this month…", "กำลังอ่านสถานะรายงานของเดือนนี้…"],
"report_calendar.failed": ["状态读取失败,暂不能确认哪些日期已生成。", "Status could not be read. Generated dates are not confirmed.", "อ่านสถานะไม่สำเร็จ ยังยืนยันวันที่สร้างรายงานแล้วไม่ได้"],
"report_calendar.coverage": ["本月已生成 {count} / {total} 天", "Reports generated for {count} of {total} days", "สร้างรายงานแล้ว {count} / {total} วัน"],
"report_calendar.help": ["选择日期查看日报;不会自动下载或处理。", "Select a date to view its report. This does not fetch or process data.", "เลือกวันที่เพื่อดูรายงาน จะไม่ดึงหรือประมวลผลข้อมูลอัตโนมัติ"],
"report_calendar.locked": ["任务进行中,结束后可切换查看日期。", "A task is in progress. Change the viewed date after it finishes.", "งานกำลังดำเนินการ เปลี่ยนวันที่ดูได้เมื่อเสร็จแล้ว"],
"report_calendar.day_group": ["选择日期查看日报", "Choose a report date", "เลือกวันที่รายงาน"],
"report_calendar.legend": ["日报生成状态", "Daily report generation status", "สถานะการสร้างรายงานรายวัน"],
"report_calendar.retry": ["重新读取", "Read again", "อ่านอีกครั้ง"],
"daily.current": ["当前有效日报", "Current daily report", "รายงานรายวันที่ใช้อยู่"],
"daily.no_current": ["无当前有效日报", "No current daily report", "ไม่มีรายงานรายวันที่ใช้อยู่"],
"daily.history_only": ["仅保留历史版本", "Historical versions only", "มีเฉพาะรุ่นในประวัติ"],
+22 -6
View File
@@ -121,22 +121,38 @@
</div>
</section>
<article class="metric-card">
<article class="metric-card arrival-metric">
<span>ARRIVAL DATE</span>
<strong id="metric-arrival">—</strong>
<small id="metric-arrival-note">当前查看日期</small>
<small id="metric-current-version" hidden>当前有效日报仍保留</small>
</article>
<article class="metric-card">
<span>处理耗时</span>
<strong id="metric-duration">—</strong>
<small id="metric-duration-note">从提交至完成</small>
</article>
<article class="metric-card accent-card">
<span>NO. OF ROOM</span>
<strong id="metric-rooms">—</strong>
<small id="metric-rooms-note">保留记录的售出房数</small>
</article>
<section class="panel report-calendar" id="report-calendar" aria-label="报表日历" aria-describedby="report-calendar-help">
<header class="report-calendar-heading">
<h2 id="report-calendar-title">报表</h2>
<button id="report-calendar-current" type="button">本月</button>
</header>
<div class="report-calendar-navigation">
<button id="report-calendar-previous" type="button" aria-label="上一个月">‹</button>
<strong id="report-calendar-month"></strong>
<button id="report-calendar-next" type="button" aria-label="下一个月">›</button>
</div>
<div class="report-calendar-weekdays" id="report-calendar-weekdays" aria-hidden="true"></div>
<div class="report-calendar-days" id="report-calendar-days" role="group" aria-label="选择日期查看日报"></div>
<div class="report-calendar-legend" aria-label="日报生成状态">
<span><b aria-hidden="true">✓</b><span id="report-calendar-legend-generated">已生成</span></span>
<span><b aria-hidden="true">—</b><span id="report-calendar-legend-empty">未生成</span></span>
</div>
<p class="report-calendar-status" id="report-calendar-status" role="status" aria-live="polite">正在读取本月报表状态…</p>
<p class="report-calendar-help" id="report-calendar-help">选择日期查看日报;不会自动下载或处理。</p>
<button class="report-calendar-retry" id="report-calendar-retry" type="button" hidden>重新读取</button>
</section>
</section>
<section class="panel arr-download-pending" id="arr-download-pending" aria-labelledby="arr-download-pending-title" hidden>
+36 -5
View File
@@ -158,7 +158,7 @@ button { color: inherit; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 22px 24px 0; }
.panel-heading h2 { margin: 4px 0 0; font-size: 18px; line-height: 1.3; letter-spacing: -.025em; }
.daily-overview { display: grid; grid-template-columns: minmax(240px, 1.25fr) minmax(280px, 1.45fr) repeat(3, minmax(0, .8fr)); gap: 16px; align-items: stretch; margin-bottom: 20px; }
.daily-overview { display: grid; grid-template-columns: minmax(220px, 1.1fr) minmax(230px, 1.15fr) minmax(150px, .75fr) minmax(110px, .55fr) minmax(360px, 1.8fr); gap: 16px; align-items: stretch; margin-bottom: 20px; }
.daily-overview > * { min-width: 0; }
.daily-overview > .upload-panel { display: flex; flex-direction: column; }
.daily-overview .upload-footer { margin-top: auto; }
@@ -272,6 +272,36 @@ button { color: inherit; }
.upload-panel { min-height: 0; overflow: hidden; border-color: #dbe3ef; box-shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 18px 44px rgba(31, 51, 82, .07); }
.daily-overview > .metric-card { align-self: stretch; display: flex; flex-direction: column; justify-content: center; height: auto; min-height: 0; padding: 24px 22px; }
.daily-overview > .metric-card::after { right: -24px; bottom: -30px; width: 82px; height: 82px; border-width: 12px; }
.daily-overview > .arrival-metric { padding-left: 16px; padding-right: 16px; }
.daily-overview > .arrival-metric strong { font-size: clamp(20px, 1.5vw, 24px); }
.report-calendar { padding: 14px 12px; overflow: hidden; }
.report-calendar-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.report-calendar-heading h2 { margin: 0; font-size: 17px; letter-spacing: -.02em; }
.report-calendar button { min-height: 44px; border: 1px solid var(--line); border-radius: 8px; color: var(--ink); background: #fff; cursor: pointer; }
.report-calendar-heading button { padding: 0 10px; color: var(--blue); font-size: 12px; }
.report-calendar-navigation { display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px; align-items: center; gap: 8px; margin: 8px 0; }
.report-calendar-navigation strong { text-align: center; font-size: 13px; font-weight: 700; }
.report-calendar-navigation button { padding: 0; font-size: 22px; }
.report-calendar-weekdays, .report-calendar-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
.report-calendar-weekdays { margin-bottom: 5px; color: var(--muted); text-align: center; font-size: 10px; }
.report-calendar-days .report-calendar-day { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; min-width: 0; padding: 3px 0; border-color: transparent; background: var(--surface-soft); font-variant-numeric: tabular-nums; }
.report-calendar-day-number { font-size: 12px; font-weight: 650; line-height: 1.3; }
.report-calendar-day-state { display: flex; align-items: center; gap: 1px; font-size: 9px; line-height: 1.4; white-space: nowrap; }
.report-calendar-days .is-generated { color: var(--blue-dark); background: var(--blue-soft); }
.report-calendar-days .is-ungenerated { color: var(--muted); }
.report-calendar-days .is-unknown { color: var(--muted); border-style: dashed; border-color: var(--line); }
.report-calendar-days .is-today { border-color: #a3b9e8; }
.report-calendar-days [aria-pressed="true"] { border-color: var(--blue); box-shadow: inset 0 0 0 1px var(--blue); color: var(--blue-dark); }
.report-calendar button:hover:not(:disabled) { border-color: var(--blue); background: var(--blue-soft); }
.report-calendar button:focus-visible { position: relative; z-index: 1; outline: 3px solid rgba(37, 99, 235, .45); outline-offset: 1px; }
.report-calendar button:disabled { cursor: not-allowed; opacity: .55; }
.report-calendar-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-top: 11px; color: var(--muted); font-size: 10px; }
.report-calendar-legend > span { display: inline-flex; align-items: center; gap: 4px; }
.report-calendar-legend > span:first-child b { color: var(--blue-dark); }
.report-calendar-status, .report-calendar-help { margin: 6px 0 0; color: var(--muted); font-size: 10px; line-height: 1.6; }
.report-calendar-status { color: var(--blue-dark); }
.report-calendar-help { color: var(--subtle); }
.report-calendar .report-calendar-retry { margin-top: 8px; padding: 0 10px; color: var(--blue); font-size: 12px; }
.upload-heading { align-items: center; padding: 24px 28px 0; }
.upload-heading h2 { margin-top: 0; font-size: 20px; letter-spacing: -.03em; }
.process-panel { min-height: 640px; overflow: hidden; }
@@ -767,10 +797,11 @@ input:focus, select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgb
:focus-visible { outline: 3px solid rgba(37, 99, 235, .35); outline-offset: 3px; }
@media (min-width: 701px) and (max-width: 1199px) {
.daily-overview { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.daily-overview > .arr-download-panel, .daily-overview > .upload-panel { grid-column: span 3; }
.daily-overview > .metric-card { grid-column: span 2; }
@media (min-width: 701px) and (max-width: 1399px) {
.daily-overview { grid-template-columns: repeat(12, minmax(0, 1fr)); }
.daily-overview > .arr-download-panel, .daily-overview > .upload-panel { grid-column: span 6; }
.daily-overview > .metric-card { grid-column: span 3; }
.daily-overview > .report-calendar { grid-column: span 6; }
}
@media (max-width: 960px) {
@@ -30,7 +30,6 @@ test('overview follows the selected incomplete date and never borrows another su
assert.equal(h.element('#metric-arrival').textContent,date);
assert.equal(h.element('#metric-arrival-note').textContent,'daily.overview_review');
assert.equal(h.element('#metric-rooms').textContent,'—');
assert.equal(h.element('#metric-duration').textContent,'—');
assert.equal(h.element('#metric-current-version').hidden,true);
assert.equal(h.calls.length,0);
});
@@ -49,7 +48,6 @@ test('a same-date pending replacement preserves the accepted day and hides ungen
assert.doesNotMatch(html,/job_id=new/);
assert.equal(h.element('#metric-arrival').textContent,date);
assert.equal(h.element('#metric-rooms').textContent,'—');
assert.equal(h.element('#metric-duration').textContent,'—');
assert.equal(h.element('#metric-current-version').hidden,false);
assert.doesNotMatch(html,/<tr[^>]+(?:tabindex|onclick|role="button")/);
});
@@ -59,7 +57,6 @@ test('a successful replacement displays only the accepted new report for the sam
h.state.dailyReports=[day(job('new',{no_of_rooms:43}))];
h.renderJobs();
assert.equal(h.element('#metric-rooms').textContent,'43');
assert.equal(h.element('#metric-duration').textContent,'52s');
assert.equal(h.element('#metric-current-version').hidden,true);
assert.match(h.element('#jobs-body').innerHTML,/job_id=new/);
assert.doesNotMatch(h.element('#jobs-body').innerHTML,/job_id=old/);
@@ -88,7 +85,6 @@ test('deleting the current report leaves older successful versions explicitly hi
assert.match(html,/data-daily-delete="superseded"[^>]+data-daily-current="false"/);
assert.equal(h.element('#metric-arrival-note').textContent,'daily.no_current');
assert.equal(h.element('#metric-rooms').textContent,'—');
assert.equal(h.element('#metric-duration').textContent,'—');
});
test('an out-of-page date reads its own overview and ignores stale and cross-context replies',async()=>{
+3 -3
View File
@@ -20,11 +20,11 @@ function harness(respond) {
return enabled;
},contains:value=>classes.has(value)},setAttribute:(key,value)=>attributes.set(key,String(value)),
removeAttribute:key=>attributes.delete(key),getAttribute:key=>attributes.get(key)??null,
querySelectorAll(){return [];},scrollIntoView(){},focus(){}});
querySelector:selector=>element(selector),querySelectorAll(){return [];},scrollIntoView(){},focus(){context.document.activeElement=this;}});
}
return elements.get(selector);
};
const context=vm.createContext({Headers, console, Date, Intl, Uint8Array,
const context=vm.createContext({Headers, console, Date, Intl, Uint8Array, TextEncoder, btoa,
document:{hidden:false,querySelector:element},
window:{ARRI18n:{t:(key,values)=>key==='manual_review.context'?`${values.date} · ${values.type}`:key,text:value=>value,errorMessage:code=>code,formatInteger:value=>String(value),formatMonth:value=>value,formatDate:value=>value,formatDurationSeconds:value=>`${value}s`},crypto:webcrypto,
setTimeout(callback,delay){const timer={id:timers.length+1,callback,delay,cancelled:false};timers.push(timer);return timer.id;},
@@ -32,7 +32,7 @@ function harness(respond) {
localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>{storageReads.push(k);return storage.get(k);},removeItem:k=>storage.delete(k)},
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body,csrf:options.headers.get('X-ARR-CSRF')});return respond(url,options,calls);},
});
const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview, strictReviewPrice, reviewPriceInput, renderDailyPriceReview, saveDailyPriceReviewItem, loadDailyPriceReview, openDailyPriceReview, dailyPriceReviewCanFinalize, dailyPriceReviewHasUnsavedChanges, finalizeDailyPriceReview, confirmDailyPriceReviewCancel, renderJobs, renderDailyOverview, syncDailyOverviewDate, loadDailyOverview, selectDailyReportDate, loadJobs, loadDailyHistory, openDailyHistory, closeDailyHistory, openDailyDelete, closeDailyDelete, confirmDailyDelete';
const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview, strictReviewPrice, reviewPriceInput, renderDailyPriceReview, saveDailyPriceReviewItem, loadDailyPriceReview, openDailyPriceReview, dailyPriceReviewCanFinalize, dailyPriceReviewHasUnsavedChanges, finalizeDailyPriceReview, confirmDailyPriceReviewCancel, handleUpload, renderJobs, renderDailyOverview, syncDailyOverviewDate, loadDailyOverview, selectDailyReportDate, loadJobs, loadDailyHistory, openDailyHistory, closeDailyHistory, openDailyDelete, closeDailyDelete, confirmDailyDelete, localMonth, localDate, shiftMonth, renderReportCalendar, loadReportCalendar, changeReportCalendarMonth, selectReportCalendarDate, moveReportCalendarFocus, reportCalendarReady, currentReportGenerated';
vm.runInContext(source.replace(boot,` globalThis.subject = {${exports}};\n})();`),context);
const subject=context.subject;
Object.assign(subject.state,{arrDownloadReady:true,arrDownloadLoaded:true,arrDownloadStorageKey:'test',arrDownloadUsername:'operator',csrf:'fixture'});
+326
View File
@@ -0,0 +1,326 @@
// Read-only calendar coverage and date selection through actual UI functions.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const {harness,response}=require('./helpers/arr_ui_harness.cjs');
const month='2026-09';
const page=(items=[],total=items.length)=>({status:200,ok:true,json:async()=>({ok:true,data:items,pagination:{total,limit:200,offset:0}})});
const row=(day,current={})=>({business_date:`${month}-${String(day).padStart(2,'0')}`,current_job:current===null?null:{
job_id:`fixture-${day}`,status:'succeeded',is_current:true,is_retired:false,no_of_rooms:0,...current},latest_job:null});
const block=(h,date)=>h.element('#report-calendar-days').innerHTML.match(new RegExp(`<button[^>]+data-report-calendar-day="${date}"[^>]*>[\\s\\S]*?<\\/button>`))?.[0];
const priceReview=()=>({case_id:'xml-price-case',case_status:'open',revision:1,business_date:`${month}-16`,
completed_items:1,total_items:1,items:[{item_id:1,company_key:'Fixture XML',rate_code:'FIXTURE',
effective_rate_amount:0,real_price:0,affected_records:1,affected_rooms:1,affected_room_nights:1,candidate_prices:[]}],
pagination:{offset:0,limit:50,total:1}});
function setup(respond=()=>page()) {
const h=harness(respond);
h.state.reportCalendarInitialized=true;
h.state.reportCalendarMonth=month;
h.state.arrDownloadContextId='hotel-fixture';
h.state.jobsMonth='2026-10';
h.state.selectedJobId='fixture-selected';
return h;
}
test('calendar defaults to the business current month and reads the whole month independently of daily pagination',async()=>{
const h=setup((url)=>{
assert.equal(url,`/api/daily-reports?month=${h.localMonth()}&limit=200&offset=0`);
return page();
});
h.state.reportCalendarMonth='';
h.state.jobsMonth='2026-01';
h.state.jobsOffset=100;
h.renderReportCalendar();
assert.equal(h.state.reportCalendarMonth,h.localMonth());
await h.loadReportCalendar();
assert.equal(h.state.reportCalendarDataMonth,h.localMonth());
assert.equal(h.state.jobsMonth,'2026-01');
assert.equal(h.state.jobsOffset,100);
assert.equal(h.calls.every(c=>c.method==='GET'),true);
});
test('only effective current successful reports are generated, including zero-room and pending-replacement days',async()=>{
const data=[row(1),{...row(2),latest_job:{status:'needs_review',is_current:false}},row(3,null),
{...row(4,null),latest_job:{status:'succeeded',is_current:false}},row(5,{is_retired:true}),
row(6,{is_current:false}),row(7,{status:'failed'}),row(8,{status:'needs_review'}),row(9,{is_current:undefined})];
const h=setup(()=>page(data));
h.state.dailyReports=[row(20)];
await h.loadReportCalendar();
for(const number of [1,2]) assert.match(block(h,`${month}-0${number}`),/is-generated/);
for(const number of [3,4,5,6,7,8,9]) assert.match(block(h,`${month}-0${number}`),/is-ungenerated/);
assert.match(block(h,`${month}-20`),/is-ungenerated/,'the current daily-list page cannot supply calendar coverage');
assert.equal(h.element('#report-calendar-status').textContent,'report_calendar.coverage');
});
test('a complete empty month marks every actual day ungenerated and renders no other-month buttons',async()=>{
const h=setup(()=>page());
await h.changeReportCalendarMonth('2026-02');
const html=h.element('#report-calendar-days').innerHTML;
assert.equal((html.match(/data-report-calendar-day=/g)||[]).length,28);
assert.equal((html.match(/is-ungenerated/g)||[]).length,28);
assert.doesNotMatch(html,/data-report-calendar-day="2026-01|data-report-calendar-day="2026-03/);
await h.changeReportCalendarMonth('2028-02');
assert.equal((h.element('#report-calendar-days').innerHTML.match(/data-report-calendar-day=/g)||[]).length,29);
});
test('loading and failed reads never label days ungenerated and a retry can restore a confirmed snapshot',async()=>{
let resolve;
let fail=true;
const h=setup(()=>fail?new Promise(r=>{resolve=r;}):page());
const reading=h.loadReportCalendar();
assert.match(block(h,`${month}-01`),/is-unknown/);
assert.doesNotMatch(h.element('#report-calendar-days').innerHTML,/is-ungenerated/);
assert.equal(h.element('#report-calendar').getAttribute('aria-busy'),'true');
resolve(response(503,null,'DATABASE_QUERY_FAILED'));
await reading;
assert.equal(h.state.reportCalendarError,true);
assert.match(block(h,`${month}-01`),/report_calendar.day_unknown/);
assert.doesNotMatch(h.element('#report-calendar-days').innerHTML,/is-ungenerated/);
assert.equal(h.element('#report-calendar-retry').hidden,false);
fail=false;
await h.loadReportCalendar();
assert.equal(h.state.reportCalendarError,false);
assert.match(block(h,`${month}-01`),/is-ungenerated/);
assert.equal(h.element('#report-calendar-retry').hidden,true);
});
for(const data of [{data:[row(1)],pagination:{total:2}},
{data:[row(1),row(1)],pagination:{total:2}},
{data:[{business_date:'2026-10-01',current_job:null}],pagination:{total:1}},
{data:[{business_date:'2026-09-01'}],pagination:{total:1}},
{data:{},pagination:{total:0}}]) {
test(`an incomplete or invalid month snapshot remains unknown (${JSON.stringify(data).slice(0,58)})`,async()=>{
const h=setup(()=>({status:200,ok:true,json:async()=>({ok:true,...data})}));
await h.loadReportCalendar();
assert.equal(h.reportCalendarReady(),false);
assert.equal(h.state.reportCalendarError,true);
assert.doesNotMatch(h.element('#report-calendar-days').innerHTML,/is-ungenerated|is-generated/);
});
}
test('late previous-month responses and failures cannot overwrite the newer month',async()=>{
let resolveOld;
const h=setup((url,options,calls)=>calls.length===1?new Promise(resolve=>{resolveOld=resolve;}):page());
const old=h.loadReportCalendar();
await h.changeReportCalendarMonth('2026-10');
resolveOld(response(503,null,'DATABASE_QUERY_FAILED'));
await old;
assert.equal(h.state.reportCalendarMonth,'2026-10');
assert.equal(h.state.reportCalendarDataMonth,'2026-10');
assert.equal(h.state.reportCalendarError,false);
assert.equal(h.state.reportCalendarLoading,false);
assert.equal(h.reportCalendarReady(),true);
});
test('late replies from the former environment cannot replace the new environment snapshot',async()=>{
let resolveOld;
const h=setup((url,options,calls)=>calls.length===1?new Promise(resolve=>{resolveOld=resolve;}):page([row(2)]));
const old=h.loadReportCalendar();
h.state.arrDownloadContextId='new-hotel-fixture';
await h.loadReportCalendar();
resolveOld(page([row(1)]));
await old;
assert.equal(h.state.reportCalendarContext,'new-hotel-fixture');
assert.match(block(h,`${month}-02`),/is-generated/);
assert.match(block(h,`${month}-01`),/is-ungenerated/);
});
test('selecting a calendar day only reads its overview and synchronizes the daily viewed month',async()=>{
const date=`${month}-17`;
const h=setup((url)=>{
if(url===`/api/daily-reports/${date}`) return response(200,{business_date:date,current_job:null,latest_job:null});
if(url.startsWith('/api/daily-reports?')) return page();
throw new Error('Unexpected request '+url);
});
const savedIntent={request_id:'a'.repeat(32),report_date:'2026-10-07'};
h.rememberARRIntent(savedIntent);
h.state.arrDataReviewDraftsByRequest={fixture:{room:'ROOM-DRAFT'}};
const selected=await h.selectReportCalendarDate(date);
assert.equal(selected,true);
assert.equal(h.state.dailyOverviewDate,date);
assert.equal(h.element('#arr-download-date').value,date);
assert.equal(h.state.jobsMonth,month);
assert.equal(h.state.jobsOffset,0);
assert.equal(h.element('#jobs-history-month').value,month);
assert.equal(h.state.arrDownloadIntent.request_id,savedIntent.request_id);
assert.equal(h.state.arrDataReviewDraftsByRequest.fixture.room,'ROOM-DRAFT');
assert.equal(h.calls.every(c=>c.method==='GET'),true);
assert.equal(h.calls.some(c=>c.url.includes('/arr-downloads')),false);
assert.match(block(h,date),/aria-pressed="true"/);
});
test('running, submission and price-edit guards preserve the old selected day and human input',async()=>{
for(const flag of ['arrDownloadBusy','arrDownloadLoading','arrDataReviewLoading','arrDataReviewMutating','arrDataReviewFinalizing',
'dailyPriceReviewLoading','dailyPriceReviewMutating','uploadInFlight','dailyDeleting']) {
const h=setup(()=>{throw new Error('No request while '+flag);});
h.state[flag]=true;
const old=h.element('#arr-download-date').value;
assert.equal(await h.selectReportCalendarDate(`${month}-17`),false);
assert.equal(h.element('#arr-download-date').value,old);
}
const h=setup(()=>{throw new Error('No request while running or editing');});
h.state.arrDownloadTask={status:'downloading',report_date:'2026-10-07',request_id:'a'.repeat(32)};
h.renderReportCalendar();
assert.match(block(h,`${month}-17`),/disabled/);
assert.equal(await h.selectReportCalendarDate(`${month}-17`),false);
h.state.arrDownloadTask=null;
h.state.dailyPriceReview={case_status:'open',items:[{item_id:1,real_price:null}]};
h.element('#daily-price-review-body').querySelectorAll=()=>[{value:'0',closest:()=>({dataset:{dailyPriceReviewItemId:'1'}})}];
assert.equal(await h.selectReportCalendarDate(`${month}-17`),false);
assert.equal(h.element('#toast').textContent,'daily_review.save_before_switch');
assert.equal(h.calls.length,0);
});
for(const outcome of ['succeeded','needs_review','failed','rejected']) {
test(`XML upload ${outcome} restores calendar selection without an OHIP pending task`,async()=>{
let resolveUpload;
const h=setup((url,options)=>{
if(url==='/api/jobs' && options.method==='POST') return new Promise(resolve=>{resolveUpload=resolve;});
if(url.startsWith('/api/daily-reports?')) return page();
if(url==='/api/jobs/fixture-xml/trace') return response(200,{job:{job_id:'fixture-xml',status:outcome,business_date:`${month}-16`}});
if(url.startsWith('/api/jobs/fixture-xml/review?')) return response(200,priceReview());
if(url===`/api/daily-reports/${month}-17`) return response(200,{business_date:`${month}-17`,current_job:null,latest_job:null});
throw new Error('Unexpected request '+url);
});
h.state.health={processing_ready:true};
h.state.selectedFile={name:'fixture.XML',arrayBuffer:async()=>new Uint8Array([60,47,62]).buffer};
await h.loadReportCalendar();
assert.doesNotMatch(block(h,`${month}-17`),/disabled/);
const uploading=h.handleUpload();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(h.state.uploadInFlight,true);
assert.match(block(h,`${month}-17`),/disabled/);
assert.equal(await h.selectReportCalendarDate(`${month}-17`),false);
resolveUpload(outcome==='rejected'?response(503,null,'UPLOAD_UNAVAILABLE'):
response(200,{job_id:'fixture-xml',business_date:`${month}-16`,status:outcome}));
await uploading;
assert.equal(h.state.uploadInFlight,false);
assert.equal(h.state.dailyPriceReviewLoading,false);
assert.equal(h.state.arrDownloadPendingReviews.length,0);
assert.doesNotMatch(block(h,`${month}-17`),/disabled/,'the upload finally must refresh disabled day buttons');
assert.equal(await h.selectReportCalendarDate(`${month}-17`),true);
assert.equal(h.calls.filter(call=>call.method!=='GET').length,1,'only the explicitly started XML upload can write');
assert.equal(h.calls.some(call=>call.url.includes('/arr-downloads')),false);
});
}
for(const action of ['finalize','cancel']) for(const outcome of action==='finalize'?['succeeded','failed','rejected']:['succeeded','rejected']) {
test(`XML price ${action} ${outcome} releases the calendar after review completion`,async()=>{
let resolveReview;
const h=setup((url,options)=>{
if(url===`/api/jobs/fixture-xml/review/${action}` && options.method==='POST') return new Promise(resolve=>{resolveReview=resolve;});
if(url.startsWith('/api/jobs/fixture-xml/review?')) return response(200,priceReview());
if(url.startsWith('/api/daily-reports?')) return page();
if(url===`/api/daily-reports/${month}-17`) return response(200,{business_date:`${month}-17`,current_job:null,latest_job:null});
throw new Error('Unexpected request '+url);
});
h.state.dailyPriceReview=priceReview();
h.state.dailyPriceReviewJobId='fixture-xml';
await h.loadReportCalendar();
assert.doesNotMatch(block(h,`${month}-17`),/disabled/);
const mutating=action==='finalize'?h.finalizeDailyPriceReview():h.confirmDailyPriceReviewCancel();
assert.equal(h.state.dailyPriceReviewMutating,true);
assert.match(block(h,`${month}-17`),/disabled/);
assert.equal(await h.selectReportCalendarDate(`${month}-17`),false);
resolveReview(outcome==='rejected'?response(503,null,'REVIEW_UNAVAILABLE'):response(200,{status:outcome}));
await mutating;
assert.equal(h.state.dailyPriceReviewMutating,false);
assert.equal(h.state.dailyPriceReviewLoading,false);
assert.equal(h.state.arrDownloadPendingReviews.length,0);
assert.doesNotMatch(block(h,`${month}-17`),/disabled/,'an XML review has no OHIP task refresh to re-enable the calendar');
assert.equal(await h.selectReportCalendarDate(`${month}-17`),true);
assert.equal(h.calls.filter(call=>call.method!=='GET').length,1,'calendar selection never replays a review decision');
assert.equal(h.calls.some(call=>call.url.includes('/arr-downloads')),false);
});
}
test('month browsing crosses years and keyboard focus crosses months without selecting or submitting',async()=>{
const h=setup(()=>page());
const oldSelection=h.element('#arr-download-date').value;
await h.changeReportCalendarMonth('2026-12');
await h.changeReportCalendarMonth(h.shiftMonth(h.state.reportCalendarMonth,1));
assert.equal(h.state.reportCalendarMonth,'2027-01');
await h.moveReportCalendarFocus('PageDown','2027-01-31');
assert.equal(h.state.reportCalendarMonth,'2027-02');
assert.equal(h.state.reportCalendarFocusDate,'2027-02-28');
await h.moveReportCalendarFocus('ArrowRight','2027-02-28');
assert.equal(h.state.reportCalendarMonth,'2027-03');
assert.equal(h.state.reportCalendarFocusDate,'2027-03-01');
await h.moveReportCalendarFocus('Home','2027-03-04');
assert.equal(h.state.reportCalendarFocusDate,'2027-03-01');
assert.equal(h.element('#arr-download-date').value,oldSelection);
assert.equal(h.calls.every(c=>c.method==='GET'),true);
assert.equal(h.calls.every(c=>c.url.includes('limit=200')),true);
});
test('refreshing daily results synchronizes effective calendar coverage after a generation or deletion',async()=>{
let generated=true;
const h=setup(url=>url.includes('limit=200')?page([row(16,generated?{}:null)]):page());
h.state.jobsMonth=month;
await h.loadJobs(false,false);
assert.match(block(h,`${month}-16`),/is-generated/);
generated=false;
await h.loadJobs(false,false);
assert.match(block(h,`${month}-16`),/is-ungenerated/);
assert.equal(h.calls.filter(c=>c.url.includes('limit=200')).length,2);
assert.equal(h.calls.every(c=>c.method==='GET'),true);
});
test('calendar labels, status legends and read failures are translated in all three locales',()=>{
const context=vm.createContext({window:{},document:{readyState:'loading',addEventListener(){}}});
vm.runInContext(fs.readFileSync(path.resolve(__dirname,'../../arr_web/static/i18n.js'),'utf8'),context);
const catalog=context.window.ARRI18n.CATALOG;
for(const key of ['title','widget_aria','generated','not_generated','unknown','loading_day',
'day_generated','day_not_generated','day_unknown','day_loading_day','loading','failed','coverage','help','locked','day_group','legend','retry']) {
const entry=catalog[`report_calendar.${key}`];
assert.equal(entry?.length,3,key);
assert.equal(entry.every(Boolean),true,key);
assert.match(entry[2],/[\u0e00-\u0e7f]/,key);
}
});
test('a slow earlier cross-month selection cannot roll the daily list back after a newer selection completes',async()=>{
let resolveOld;
const oldDate='2026-09-16',newDate='2026-10-07';
const h=setup(url=>{
if(url===`/api/daily-reports/${oldDate}`) return new Promise(resolve=>{resolveOld=resolve;});
if(url===`/api/daily-reports/${newDate}`) return response(200,{business_date:newDate,current_job:null,latest_job:null});
throw new Error('No stale list read expected '+url);
});
const earlier=h.selectDailyReportDate(oldDate,{syncHistory:true});
assert.equal(await h.selectDailyReportDate(newDate,{syncHistory:true}),true);
resolveOld(response(200,{business_date:oldDate,current_job:null,latest_job:null}));
assert.equal(await earlier,false);
assert.equal(h.state.dailyOverviewDate,newDate);
assert.equal(h.state.jobsMonth,'2026-10');
assert.equal(h.calls.every(c=>c.method==='GET'),true);
assert.equal(h.calls.length,2);
});
test('same-date selections across an environment A to B to A transition reject the first A response',async()=>{
let resolveOld;
const date='2026-09-16';
const h=setup((url,options,calls)=>{
if(url===`/api/daily-reports/${date}`) {
if(calls.length===1) return new Promise(resolve=>{resolveOld=resolve;});
return response(200,{business_date:date,current_job:null,latest_job:null});
}
if(url.startsWith('/api/daily-reports?')) return page();
throw new Error('Unexpected request '+url);
});
h.state.jobsMonth=month;
const earlier=h.selectDailyReportDate(date,{syncHistory:true});
h.state.arrDownloadContextId='environment-B';
h.state.dailyOverview=null;
assert.equal(await h.selectDailyReportDate(date,{syncHistory:true}),true);
h.state.arrDownloadContextId='hotel-fixture';
h.state.dailyOverview=null;
assert.equal(await h.selectDailyReportDate(date,{syncHistory:true}),true);
resolveOld(response(200,{business_date:date,current_job:row(16).current_job,latest_job:null}));
assert.equal(await earlier,false);
assert.equal(h.state.dailyOverview.current_job,null);
assert.equal(h.state.dailyOverviewDate,date);
assert.equal(h.state.jobsMonth,month);
});
+30 -4
View File
@@ -36,17 +36,18 @@ class DailyVisualStaticContractTests(unittest.TestCase):
overview_markup = self.html[overview_start:overview_end]
self.assertIn('id="upload-title"', overview_markup)
self.assertLess(overview_markup.index('id="arr-download-title"'), overview_markup.index('id="upload-title"'))
for metric_id in ('id="metric-arrival"', 'id="metric-duration"', 'id="metric-rooms"'):
for metric_id in ('id="metric-arrival"', 'id="metric-rooms"', 'id="report-calendar"'):
self.assertIn(metric_id, overview_markup)
self.assertLess(overview_markup.index('id="upload-title"'), overview_markup.index('id="metric-arrival"'))
self.assertLess(overview_markup.index('id="metric-arrival"'), overview_markup.index('id="metric-duration"'))
self.assertLess(overview_markup.index('id="metric-duration"'), overview_markup.index('id="metric-rooms"'))
self.assertLess(overview_markup.index('id="metric-arrival"'), overview_markup.index('id="metric-rooms"'))
self.assertLess(overview_markup.index('id="metric-rooms"'), overview_markup.index('id="report-calendar"'))
self.assertNotIn('id="metric-duration"', overview_markup)
self.assertIn(
'class="daily-overview" aria-label="日报处理概览"',
overview_markup,
)
self.assertIn(
".daily-overview { display: grid; grid-template-columns: minmax(240px, 1.25fr) minmax(280px, 1.45fr) repeat(3, minmax(0, .8fr));",
".daily-overview { display: grid; grid-template-columns: minmax(220px, 1.1fr) minmax(230px, 1.15fr) minmax(150px, .75fr) minmax(110px, .55fr) minmax(360px, 1.8fr);",
self.styles,
)
self.assertIn(".daily-overview > .metric-card", self.styles)
@@ -73,6 +74,31 @@ class DailyVisualStaticContractTests(unittest.TestCase):
self.assertIn("min-width: 118px", self.styles)
self.assertNotIn("width: calc(100% - 48px)", self.styles)
def test_report_calendar_has_native_navigation_text_status_and_read_failure_feedback(self) -> None:
start = self.html.index('id="report-calendar"')
end = self.html.index("</section>", start)
calendar = self.html[start:end]
self.assertIn('aria-label="报表日历" aria-describedby="report-calendar-help"', calendar)
self.assertIn('<h2 id="report-calendar-title">报表</h2>', calendar)
for control in ("current", "previous", "next", "retry"):
self.assertRegex(calendar, rf'<button[^>]*id="report-calendar-{control}"[^>]*type="button"')
self.assertIn('id="report-calendar-days" role="group" aria-label="选择日期查看日报"', calendar)
self.assertIn('id="report-calendar-legend-generated">已生成</span>', calendar)
self.assertIn('id="report-calendar-legend-empty">未生成</span>', calendar)
self.assertIn('id="report-calendar-status" role="status" aria-live="polite"', calendar)
self.assertIn('id="report-calendar-retry" type="button" hidden', calendar)
self.assertIn("repeat(7, minmax(0, 1fr))", self.styles)
self.assertIn(".report-calendar button:focus-visible", self.styles)
self.assertIn(".report-calendar button { min-height: 44px;", self.styles)
self.assertIn("@media (min-width: 701px) and (max-width: 1399px)", self.styles)
def test_duration_is_removed_only_from_the_top_overview(self) -> None:
app = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
self.assertNotIn('id="metric-duration"', self.html)
self.assertNotIn('$("#metric-duration")', app)
self.assertIn("处理耗时", self.html[self.html.index('id="daily-history-title"'):])
self.assertIn('current ? duration(current) : "—"', app)
def test_download_card_exposes_accessible_retrieval_progress_with_reduced_motion(self) -> None:
start = self.html.index('id="arr-acquisition-progress"')
end = self.html.index('<div class="arr-download-actions">', start)