Make report coverage calendar expandable

This commit is contained in:
Wyndham ARR committed 2026-10-09 13:55:41 +08:00
1 parent 4356160cc9
commit 6d1a98743f
11 files changed
+283 -57

No files matched your search

@@ -12,7 +12,7 @@
## 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.
- Replace the daily header processing-duration card with report coverage after ARRIVAL DATE and NO. OF ROOM. User visual revision: show a compact calendar button like automatic ARR download, expand the month calendar on demand, color generated dates green, and name the card “本月报表情况”. Keep independent whole-month status, month browsing and read-only date selection.
## Intent And Constraints
@@ -20,9 +20,9 @@
## 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.
- Header now presents automatic download, XML upload, ARRIVAL DATE, NO. OF ROOM and “本月报表情况” in that order. The report calendar is a compact, closed-by-default month button; generated dates are green with a check mark when expanded. 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.
- Three locales, status legends and accessible date 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. The popup supports outside-click, Tab, Escape and explicit close, and is mutually exclusive with the acquisition date calendar.
- 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.
@@ -42,7 +42,7 @@
## 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.
- Target: current-state.md and portal architecture/domain memory. Proposal: record effective-report month coverage through a closed-by-default “本月报表情况” button, green generated dates with check marks, header order without duration, read-only calendar selection, and separate viewed date/review context. Evidence: explicit user visual revision, 138 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. The direct user revision supersedes the earlier always-visible calendar layout. No semantic conflict with accepted processing rules; user approval already supplied. Feature mode records this candidate without canonical writes.
## Concurrent Task Gate
@@ -64,3 +64,16 @@
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.
## Visual Revision (2026-10-09)
- Same-task resume: task_context start/status passed for the same task/owner/branch/worktree; current HEAD4356160 is published and local8875 already runs this worktree. Peer Scope/Constraints/Promotion Candidates remain read-only; primary dirty checkout untouched.
- Planning context: mandatory project memory was rechecked; canonical September context remains stale compared with explicit October user instructions and completed local evidence. The user replaces the previously approved expanded layout with a popup button and green current-report dates; this explicit revision governs the design. No business-rule, API, authentication, source or monthly change. Gate Passed.
- Design direction: reuse the existing automatic-download calendar control and blue/white page styling, compact five-card header, readable green generated dates with status text/check marks, keyboard/focus/outside-click/Escape handling, and 375/768/1024/1440 layout checks. Generic design search suggestions are adapted to this existing product instead of introducing glass/dark effects.
- Plan: implement closed-by-default calendar popup and labels/colors, verify date/month/closing guards and responsive layout, refresh local static assets, document and publish the scoped revision. No service restart or Oracle query is needed for static assets.
- Revision outcome: replaced the always-visible grid with a compact month/calendar button. Popup positioning does not increase the header height. Generated dates remain green while selected or hovered and also show a check mark; ungenerated dates use neutral styling. Successful date selection closes the popup and returns focus to the month button. Unsaved-price/busy guards keep the popup open, while late responses cannot reopen a closed popup or close a newly reopened one. README and direct-entry documentation now describe this final behavior.
- Verification: 138 JavaScript tests and 15 relevant daily visual/price-review checks passed, with syntax and diff checks passing. Independent readonly popup review confirmed close/focus guards, mutual exclusion, price0 protection, late-response protection and GET-only selection. Synthetic browser proof covered closed default, month browsing, date selection, Escape, mutually exclusive calendars and actual CSS widths375/767/1024/1440 without horizontal overflow or clipped popup width; mobile dates are approximately44px targets.
- Actual8875 proof: title “本月报表情况”, closed default, green/check September16/17 dates, ungenerated September13/15, and read-only selections showing September16/150 rooms then September17/43 rooms. Actual header height275.78125px was unchanged by opening. Selected date closes the popup with focus restored. Saved private live-popup-closed.png and live-popup-open.png under report-calendar-popup-validation-20261009; final page left compact/closed. No service restart, Oracle query, acquisition, upload, report deletion or review submission was performed.
- Preservation: before/after comparison again retained all27 table fingerprints, 3 current report pointers, 4896 original source files, monthly workbook/API records, queue statuses, pending datesSeptember15/13 and retirement audit.
- Browser-discovered fix: delegated date clicks synchronously rerender the calendar and remove the original click target before document bubbling. An outside-click check using only contains(target) misclassified this as external and lost focus. The final implementation checks the event composedPath before contains; a full delegation/rerender/bubbling test and actual synthetic/live date clicks verified closure and restored focus.
- Publication: same approved arr0918/main destination; a private receipt records the verified final commit after pushing. Keep this worktree because8875 serves it; no checkout cleanup is appropriate while it is in use.
@@ -0,0 +1,8 @@
# Delegated popup clicks during synchronous rerender
- Source task: 20261009-report-calendar-d19a
- Evidence: synthetic/live browser date selection and the added full delegation/rerender/document-bubbling JavaScript test.
- Finding: a delegated click handler can synchronously replace its clicked DOM node before the document outside-click handler runs. A contains(event.target) check then falsely classifies an internal click as external, prematurely closes the popup and loses the intended focus restoration.
- Reusable practice: preserve event-origin identity with event.composedPath and retain contains as fallback. Test the complete event chain, including target removal and document bubbling, rather than testing the selection helper alone. Verify resulting focus in the browser.
- Scope: frontend event handling only; no report or processing-rule semantics changed.
- Promotion candidate: document this pattern in frontend verification guidance during serialized integration if useful; no canonical guidance changed here.
+1 -1
View File
@@ -35,7 +35,7 @@
点击列表中的日期,可查看该日概览;上方日期与房数始终对应当前查看日期。待处理新版本会单独提示,尚无有效日报时显示空值,不借用其他日期的成功结果。
顶部概览提供“自动下载 ARR、上传 ARR.XML、ARRIVAL DATE、NO. OF ROOM、报表”五个区域,顶部不再展示处理耗时。报表日历默认显示当前业务月份,支持切换月份;每一天按是否存在当前有效日报标为“已生成”或“未生成”。历史版本、已删除版本和仅待核对的任务不算已生成;已有有效日报、另有待核对新版本时,仍标为已生成。读取中或读取失败会单独显示,不把未知状态当作未生成。
顶部概览提供“自动下载 ARR、上传 ARR.XML、ARRIVAL DATE、NO. OF ROOM、本月报表情况”五个区域,顶部不再展示处理耗时。“本月报表情况”默认收起,显示月份和已生成天数;点击带日历图标的月份按钮展开月历,支持切换月份。每一天按是否存在当前有效日报标为“已生成”或“未生成”,已有报表的日期显示为绿色。历史版本、已删除版本和仅待核对的任务不算已生成;已有有效日报、另有待核对新版本时,仍标为已生成。读取中或读取失败会单独显示,不把未知状态当作未生成。
点击日历日期只切换查看日期和日报列表月份,不会获取Oracle数据、生成报表或提交人工核对值。需要下载新数据时,仍须在自动下载入口主动提交;未保存的人工价格继续受切换保护。日历月份与列表分页独立,显示整月状态;生成或删除后的刷新会更新标记。
+1 -1
View File
@@ -2,7 +2,7 @@
2026-10-09:日报按日期展示一个有效版本,支持查看历史、删除及月报同步;概览跟随所选日期。发布需要数据库022及配套月报worker,详见[交付说明](../deploy/OHIP_RELEASE_HANDOVER.md#日报版本管理升级2026-10-09)。
顶部新增“报表”月历,按当前有效日报显示已生成/未生成日期,可切换月份和选择日期查看;顶部处理耗时已移除。日历只读取ARR已保存状态,不查询Oracle或自动创建报表。
顶部“本月报表情况”提供可展开的日历按钮,有当前有效日报的日期显示为绿色,可切换月份和选择日期查看;顶部处理耗时已移除。日历只读取ARR已保存状态,不查询Oracle或自动创建报表。
2026-09-18:[直接数据入口](DIRECT_DATA_ENTRY.md)已接通日期按钮、日报、复核和月报;独立本机模拟8875,未查询实际Oracle。
+53 -6
View File
@@ -69,6 +69,8 @@
reportCalendarRequest: 0,
reportCalendarFocusDate: "",
reportCalendarInitialized: false,
reportCalendarOpen: false,
reportCalendarOpenSequence: 0,
dailyHistoryDate: "",
dailyHistoryItems: [],
dailyHistoryTotal: 0,
@@ -242,6 +244,7 @@
function openARRCalendar() {
const input = $("#arr-download-date");
if (input.disabled) return;
closeReportCalendar();
arrCalendar.focusedDay = validARRDate(input.value) ? input.value : defaultARRDate();
arrCalendar.month = arrCalendar.focusedDay.slice(0, 7);
arrCalendar.open = true;
@@ -1356,6 +1359,23 @@
&& state.reportCalendarContext === state.arrDownloadContextId;
}
function closeReportCalendar(restoreFocus = false) {
if (!state.reportCalendarOpen) return;
state.reportCalendarOpen = false;
state.reportCalendarOpenSequence += 1;
$("#report-calendar-popup").hidden = true;
$("#report-calendar-toggle").setAttribute("aria-expanded", "false");
if (restoreFocus) $("#report-calendar-toggle").focus();
}
function openReportCalendar() {
closeARRCalendar();
state.reportCalendarOpen = true;
state.reportCalendarOpenSequence += 1;
renderReportCalendar(true);
if (reportCalendarSelectionLocked()) $("#report-calendar-close").focus();
}
function renderReportCalendar(focusDay = false) {
const month = state.reportCalendarMonth || localMonth();
state.reportCalendarMonth = month;
@@ -1369,7 +1389,7 @@
: today.slice(0, 7) === month ? today : `${month}-01`;
}
const activeDay = document.activeElement?.closest?.("[data-report-calendar-day]");
const restoreDayFocus = focusDay || Boolean(activeDay);
const restoreDayFocus = state.reportCalendarOpen && (focusDay || Boolean(activeDay));
const ready = reportCalendarReady();
const locked = reportCalendarSelectionLocked();
const format = (options) => new Intl.DateTimeFormat(I18N?.locale || "zh-CN", { timeZone: "UTC", calendar: "gregory", ...options });
@@ -1390,28 +1410,36 @@
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>`);
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>${exists ? '<span class="report-calendar-day-check" aria-hidden="true">✓</span>' : ""}</button>`);
}
$("#report-calendar-month").textContent = I18N.formatMonth(month);
$("#report-calendar-title").textContent = I18N.t("report_calendar.title");
$("#report-calendar-button-month").textContent = I18N.formatMonth(month);
$("#report-calendar-toggle").setAttribute("aria-label", I18N.t("report_calendar.open", { month: I18N.formatMonth(month) }));
$("#report-calendar-toggle").setAttribute("aria-expanded", String(state.reportCalendarOpen));
$("#report-calendar-popup").hidden = !state.reportCalendarOpen;
$("#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-close").textContent = I18N.t("common.close");
$("#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-summary").textContent = I18N.t(state.reportCalendarError ? "report_calendar.failed_summary"
: !ready ? "report_calendar.loading_summary" : "report_calendar.coverage",
{ 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();
if (restoreDayFocus && !locked) $(`[data-report-calendar-day="${state.reportCalendarFocusDate}"]`, $("#report-calendar-days"))?.focus();
else if (restoreDayFocus && activeDay) $("#report-calendar-close").focus();
}
async function loadReportCalendar() {
@@ -1467,13 +1495,15 @@
if (!validARRDate(date) || date.slice(0, 7) !== state.reportCalendarMonth || reportCalendarSelectionLocked()) return false;
const context = state.arrDownloadContextId;
const month = state.reportCalendarMonth;
const openSequence = state.reportCalendarOpenSequence;
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);
renderReportCalendar();
if (state.reportCalendarOpen && openSequence === state.reportCalendarOpenSequence) closeReportCalendar(true);
}
return selected;
}
@@ -1502,6 +1532,11 @@
}
function bindReportCalendar() {
const control = $("#report-calendar");
// A day selection can replace its target before the click reaches document.
const originatedInside = (event) => event.composedPath?.().includes(control) || control.contains(event.target);
$("#report-calendar-toggle").addEventListener("click", () => state.reportCalendarOpen ? closeReportCalendar(true) : openReportCalendar());
$("#report-calendar-close").addEventListener("click", () => closeReportCalendar(true));
$("#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()));
@@ -1516,6 +1551,18 @@
event.preventDefault();
moveReportCalendarFocus(event.key, day.dataset.reportCalendarDay);
});
document.addEventListener("click", (event) => {
if (state.reportCalendarOpen && !originatedInside(event)) closeReportCalendar();
});
document.addEventListener("focusin", (event) => {
if (state.reportCalendarOpen && !originatedInside(event)) closeReportCalendar();
});
document.addEventListener("keydown", (event) => {
if (state.reportCalendarOpen && event.key === "Escape") {
event.preventDefault();
closeReportCalendar(true);
}
});
}
function dailyJobSource(job) {
+4 -1
View File
@@ -254,8 +254,11 @@
"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.title": ["本月报表情况", "Monthly report status", "สถานะรายงานเดือนนี้"],
"report_calendar.widget_aria": ["报表日历", "Report calendar", "ปฏิทินรายงาน"],
"report_calendar.open": ["查看 {month} 报表情况", "View report status for {month}", "ดูสถานะรายงานของ {month}"],
"report_calendar.loading_summary": ["正在读取…", "Loading…", "กำลังอ่าน…"],
"report_calendar.failed_summary": ["状态暂不可用,请展开重试。", "Status unavailable. Open to retry.", "สถานะไม่พร้อม เปิดเพื่อลองอีกครั้ง"],
"report_calendar.generated": ["已生成", "Generated", "สร้างแล้ว"],
"report_calendar.not_generated": ["未生成", "Not generated", "ยังไม่สร้าง"],
"report_calendar.loading_day": ["读取中", "Loading", "กำลังอ่าน"],
+27 -17
View File
@@ -134,24 +134,34 @@
</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>
<h2 id="report-calendar-title">本月报表情况</h2>
<div class="report-calendar-control">
<button class="report-calendar-toggle" id="report-calendar-toggle" type="button" aria-haspopup="dialog" aria-controls="report-calendar-popup" aria-expanded="false">
<span id="report-calendar-button-month"></span>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Zm2-2v4m10-4v4M4 10h16"/></svg>
</button>
<div class="report-calendar-popup" id="report-calendar-popup" role="dialog" aria-labelledby="report-calendar-title report-calendar-month" aria-describedby="report-calendar-help" hidden>
<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>
<div class="report-calendar-footer">
<button id="report-calendar-current" type="button">本月</button>
<button id="report-calendar-close" type="button">关闭</button>
</div>
</div>
</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>
<p class="report-calendar-summary" id="report-calendar-summary" role="status" aria-live="polite">正在读取…</p>
</section>
</section>
+22 -15
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(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 { display: grid; grid-template-columns: minmax(220px, 1.15fr) minmax(230px, 1.2fr) minmax(155px, .8fr) minmax(125px, .65fr) minmax(220px, 1.15fr); 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; }
@@ -274,34 +274,41 @@ button { color: inherit; }
.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 { position: relative; display: flex; flex-direction: column; justify-content: center; padding: 22px; overflow: visible; }
.report-calendar h2 { margin: 0 0 16px; font-size: 17px; line-height: 1.4; 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-control { position: relative; }
.report-calendar .report-calendar-toggle { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-color: var(--line-strong); background: var(--surface-soft); text-align: left; font: inherit; font-size: 13px; }
.report-calendar-toggle svg { flex: 0 0 auto; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.report-calendar-popup { position: absolute; top: calc(100% + 8px); right: 0; z-index: 45; width: min(348px, calc(100vw - 24px)); padding: 12px; border: 1px solid var(--line-strong); border-radius: 12px; background: #fff; box-shadow: 0 14px 36px rgba(16, 24, 40, .18); }
.report-calendar-popup[hidden] { display: none; }
.report-calendar-navigation { display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px; align-items: center; gap: 8px; margin-bottom: 10px; }
.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 .report-calendar-day { position: relative; display: flex; align-items: center; justify-content: center; min-width: 0; padding: 3px 0; border-color: transparent; background: var(--surface-soft); font-variant-numeric: tabular-nums; }
.report-calendar-day-number { font-size: 14px; font-weight: 600; line-height: 1.3; }
.report-calendar-day-check { position: absolute; top: 2px; right: 4px; font-size: 10px; line-height: 1.3; }
.report-calendar-days .is-generated { color: #076247; background: #dff5e9; }
.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-days [aria-pressed="true"] { border-color: var(--blue); box-shadow: inset 0 0 0 1px var(--blue); }
.report-calendar button:hover:not(:disabled) { border-color: var(--blue); background: var(--blue-soft); }
.report-calendar-days .is-generated:hover:not(:disabled) { color: #076247; background: #c9eedc; }
.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-legend > span:first-child b { color: #076247; }
.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; }
.report-calendar-summary { margin: 10px 0 0; color: var(--muted); font-size: 11px; line-height: 1.6; }
.report-calendar-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
.report-calendar-footer button { padding: 0 12px; 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; }
@@ -797,11 +804,10 @@ 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: 1399px) {
@media (min-width: 701px) and (max-width: 1199px) {
.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; }
.daily-overview > .metric-card, .daily-overview > .report-calendar { grid-column: span 4; }
}
@media (max-width: 960px) {
@@ -903,6 +909,7 @@ input:focus, select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgb
.upload-panel .primary-button { min-width: 112px; min-height: 42px; padding: 0 16px; }
.metric-card { min-height: 112px; }
.daily-overview > .metric-card { height: auto; min-height: 0; }
.report-calendar-popup { right: -22px; width: min(348px, calc(100vw - 28px)); }
.process-panel { min-height: 0; }
.trace-log-frame { margin-left: 18px; margin-right: 18px; }
.trace-log { min-height: 400px; max-height: 520px; }
+10 -6
View File
@@ -9,10 +9,10 @@ const boot = ' boot();\n})();';
assert(source.endsWith(boot+'\n') || source.endsWith(boot));
function harness(respond) {
const elements=new Map(), storage=new Map(), storageReads=[], calls=[], timers=[];
const elements=new Map(), storage=new Map(), storageReads=[], calls=[], timers=[], documentListeners=new Map();
const element=selector => {
if (!elements.has(selector)) {
const attributes=new Map(),classes=new Set();
const attributes=new Map(),classes=new Set(),listeners=new Map();
elements.set(selector,{value:'', disabled:false, hidden:false, textContent:'', innerHTML:'',style:{},
classList:{add:value=>classes.add(value),remove:value=>classes.delete(value),toggle(value,force){
const enabled=force===undefined?!classes.has(value):force;
@@ -20,19 +20,21 @@ 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,
querySelector:selector=>element(selector),querySelectorAll(){return [];},scrollIntoView(){},focus(){context.document.activeElement=this;}});
querySelector:selector=>element(selector),querySelectorAll(){return [];},scrollIntoView(){},focus(){context.document.activeElement=this;},
addEventListener(type,callback){if(!listeners.has(type)) listeners.set(type,[]);listeners.get(type).push(callback);},
dispatchEvent(event){for(const callback of listeners.get(event.type)||[]) callback(event);},contains(node){return node===this;}});
}
return elements.get(selector);
};
const context=vm.createContext({Headers, console, Date, Intl, Uint8Array, TextEncoder, btoa,
document:{hidden:false,querySelector:element},
document:{hidden:false,querySelector:element,addEventListener(type,callback){if(!documentListeners.has(type)) documentListeners.set(type,[]);documentListeners.get(type).push(callback);}},
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;},
clearTimeout(id){const timer=timers.find(entry=>entry.id===id);if(timer) timer.cancelled=true;},location:{replace(){}}},
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, handleUpload, renderJobs, renderDailyOverview, syncDailyOverviewDate, loadDailyOverview, selectDailyReportDate, loadJobs, loadDailyHistory, openDailyHistory, closeDailyHistory, openDailyDelete, closeDailyDelete, confirmDailyDelete, localMonth, localDate, shiftMonth, renderReportCalendar, loadReportCalendar, changeReportCalendarMonth, selectReportCalendarDate, moveReportCalendarFocus, reportCalendarReady, currentReportGenerated';
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, openReportCalendar, closeReportCalendar, bindReportCalendar, openARRCalendar, closeARRCalendar, arrCalendar';
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'});
@@ -42,7 +44,9 @@ function harness(respond) {
const node={dataset:{arrDataReviewItemId:itemId},querySelector:()=>input};
return {input,button:{closest:()=>node}};
};
return {...subject,calls,storage,storageReads,timers,element,row,i18n:context.window.ARRI18n,submit:()=>subject.submitARRDownload({preventDefault(){}})};
return {...subject,calls,storage,storageReads,timers,element,row,i18n:context.window.ARRI18n,document:context.document,
emitDocumentEvent(event){for(const callback of documentListeners.get(event.type)||[]) callback(event);},
submit:()=>subject.submitARRDownload({preventDefault(){}})};
}
const response=(status,data,code) => ({status,ok:status<400,json:async()=>code?{ok:false,error:{code}}:{ok:true,data}});
const scopedKey=(context='production',username='operator')=>`arr:last-download:v2:${encodeURIComponent(context)}:${encodeURIComponent(username)}`;
+129 -2
View File
@@ -76,7 +76,7 @@ test('loading and failed reads never label days ungenerated and a retry can rest
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.match(block(h,`${month}-01`),/report_calendar.unknown/);
assert.doesNotMatch(h.element('#report-calendar-days').innerHTML,/is-ungenerated/);
assert.equal(h.element('#report-calendar-retry').hidden,false);
fail=false;
@@ -272,7 +272,7 @@ test('calendar labels, status legends and read failures are translated in all th
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',
for(const key of ['title','widget_aria','open','loading_summary','failed_summary','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);
@@ -324,3 +324,130 @@ test('same-date selections across an environment A to B to A transition reject t
assert.equal(h.state.dailyOverviewDate,date);
assert.equal(h.state.jobsMonth,month);
});
test('the compact calendar starts closed and its popup and ARR date picker are mutually exclusive',async()=>{
const h=setup(()=>page([row(16)]));
await h.loadReportCalendar();
assert.equal(h.element('#report-calendar-popup').hidden,true);
assert.equal(h.element('#report-calendar-toggle').getAttribute('aria-expanded'),'false');
assert.equal(h.element('#report-calendar-summary').textContent,'report_calendar.coverage');
const reads=h.calls.length;
h.openARRCalendar();
assert.equal(h.arrCalendar.open,true);
h.openReportCalendar();
assert.equal(h.arrCalendar.open,false);
assert.equal(h.state.reportCalendarOpen,true);
assert.equal(h.element('#report-calendar-popup').hidden,false);
assert.equal(h.element('#report-calendar-toggle').getAttribute('aria-expanded'),'true');
assert.match(block(h,`${month}-16`),/report-calendar-day-check/,'a generated day has a non-color check mark');
assert.doesNotMatch(block(h,`${month}-17`),/report-calendar-day-check|report-calendar-day-state/);
h.openARRCalendar();
assert.equal(h.state.reportCalendarOpen,false);
assert.equal(h.element('#report-calendar-popup').hidden,true);
assert.equal(h.arrCalendar.open,true);
assert.equal(h.calls.length,reads,'opening either calendar is a view action');
});
test('Escape and close return focus to the trigger while outside click or keyboard focus preserve the destination',()=>{
const h=setup(()=>{throw new Error('Closing must not request data');});
h.bindReportCalendar();
h.openReportCalendar();
let prevented=false;
h.emitDocumentEvent({type:'keydown',key:'Escape',preventDefault(){prevented=true;}});
assert.equal(prevented,true);
assert.equal(h.state.reportCalendarOpen,false);
assert.equal(h.document.activeElement,h.element('#report-calendar-toggle'));
h.openReportCalendar();
h.element('#report-calendar-close').dispatchEvent({type:'click'});
assert.equal(h.state.reportCalendarOpen,false);
assert.equal(h.document.activeElement,h.element('#report-calendar-toggle'));
for(const type of ['click','focusin']) {
h.openReportCalendar();
const destination=h.element('#xml-file');
destination.focus();
h.emitDocumentEvent({type,target:destination});
assert.equal(h.state.reportCalendarOpen,false);
assert.equal(h.document.activeElement,destination,'dismissal cannot steal focus from the next operation');
}
});
test('successful read-only selection closes the popup but busy or unsaved prices keep it open without discarding input',async()=>{
const date=`${month}-17`;
const h=setup(url=>url===`/api/daily-reports/${date}`?response(200,{business_date:date,current_job:null,latest_job:null}):page());
await h.loadReportCalendar();
h.openReportCalendar();
h.state.uploadInFlight=true;
assert.equal(await h.selectReportCalendarDate(date),false);
assert.equal(h.state.reportCalendarOpen,true);
h.state.uploadInFlight=false;
h.state.dailyPriceReview={case_status:'open',items:[{item_id:1,real_price:null}]};
const input={value:'0',closest:()=>({dataset:{dailyPriceReviewItemId:'1'}})};
h.element('#daily-price-review-body').querySelectorAll=()=>[input];
assert.equal(await h.selectReportCalendarDate(date),false);
assert.equal(h.state.reportCalendarOpen,true);
assert.equal(input.value,'0');
assert.equal(h.element('#arr-download-date').value,`${month}-16`);
h.state.dailyPriceReview=null;
assert.equal(await h.selectReportCalendarDate(date),true);
assert.equal(h.state.reportCalendarOpen,false);
assert.equal(h.element('#report-calendar-popup').hidden,true);
assert.equal(h.document.activeElement,h.element('#report-calendar-toggle'));
assert.equal(h.calls.every(call=>call.method==='GET'),true);
assert.equal(h.calls.some(call=>call.url.includes('/arr-downloads')),false);
});
test('a hidden popup never captures focus during a late month refresh or keyboard-render request',async()=>{
let resolveRead;
const h=setup(()=>new Promise(resolve=>{resolveRead=resolve;}));
h.openReportCalendar();
const pending=h.loadReportCalendar();
const hiddenDay=h.element(`[data-report-calendar-day="${h.state.reportCalendarFocusDate}"]`);
hiddenDay.closest=()=>hiddenDay;
h.closeReportCalendar();
let focusAttempts=0;
hiddenDay.focus=()=>{focusAttempts+=1;};
h.document.activeElement=hiddenDay;
resolveRead(page());
await pending;
h.renderReportCalendar(true);
assert.equal(focusAttempts,0);
assert.equal(h.element('#report-calendar-popup').hidden,true);
});
test('a late day selection cannot close a popup opened again after the original popup was dismissed',async()=>{
let resolveSelection;
const date=`${month}-17`;
const h=setup(url=>url===`/api/daily-reports/${date}`?new Promise(resolve=>{resolveSelection=resolve;}):page());
await h.loadReportCalendar();
h.openReportCalendar();
const selecting=h.selectReportCalendarDate(date);
h.closeReportCalendar();
h.openReportCalendar();
resolveSelection(response(200,{business_date:date,current_job:null,latest_job:null}));
assert.equal(await selecting,true);
assert.equal(h.state.reportCalendarOpen,true);
assert.equal(h.element('#report-calendar-popup').hidden,false);
});
test('a delegated day click stays internal after synchronous rendering removes its target and restores trigger focus on success',async()=>{
let resolveSelection;
const date=`${month}-17`;
const h=setup(url=>url===`/api/daily-reports/${date}`?new Promise(resolve=>{resolveSelection=resolve;}):page());
await h.loadReportCalendar();
h.bindReportCalendar();
h.openReportCalendar();
const originalButton={dataset:{reportCalendarDay:date},closest(){return this;}};
const control=h.element('#report-calendar');
const event={type:'click',target:originalButton,composedPath:()=>[
originalButton,h.element('#report-calendar-days'),h.element('#report-calendar-popup'),control]};
h.element('#report-calendar-days').dispatchEvent(event);
assert.equal(h.state.dailyOverviewDate,date,'the delegated handler has already synchronously rerendered the days');
assert.equal(control.contains(originalButton),false,'the original click target is no longer a current DOM descendant');
h.emitDocumentEvent(event);
assert.equal(h.state.reportCalendarOpen,true,'document must honor the event origin instead of the removed target');
resolveSelection(response(200,{business_date:date,current_job:null,latest_job:null}));
await new Promise(resolve=>setImmediate(resolve));
assert.equal(h.state.reportCalendarOpen,false);
assert.equal(h.document.activeElement,h.element('#report-calendar-toggle'));
assert.equal(h.calls.every(call=>call.method==='GET'),true);
});
+11 -4
View File
@@ -47,7 +47,7 @@ class DailyVisualStaticContractTests(unittest.TestCase):
overview_markup,
)
self.assertIn(
".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);",
".daily-overview { display: grid; grid-template-columns: minmax(220px, 1.15fr) minmax(230px, 1.2fr) minmax(155px, .8fr) minmax(125px, .65fr) minmax(220px, 1.15fr);",
self.styles,
)
self.assertIn(".daily-overview > .metric-card", self.styles)
@@ -79,9 +79,12 @@ class DailyVisualStaticContractTests(unittest.TestCase):
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.assertIn('<h2 id="report-calendar-title">本月报表情况</h2>', calendar)
for control in ("toggle", "current", "previous", "next", "retry", "close"):
self.assertRegex(calendar, rf'<button[^>]*id="report-calendar-{control}"[^>]*type="button"')
self.assertIn('aria-haspopup="dialog" aria-controls="report-calendar-popup" aria-expanded="false"', calendar)
self.assertIn('id="report-calendar-popup" role="dialog"', calendar)
self.assertIn('aria-describedby="report-calendar-help" hidden', calendar)
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)
@@ -90,7 +93,11 @@ class DailyVisualStaticContractTests(unittest.TestCase):
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)
self.assertIn("@media (min-width: 701px) and (max-width: 1199px)", self.styles)
self.assertIn(".report-calendar-popup[hidden] { display: none; }", self.styles)
self.assertIn(".report-calendar-popup { position: absolute;", self.styles)
self.assertIn(".report-calendar-days .is-generated { color: #076247; background: #dff5e9; }", self.styles)
self.assertNotIn('box-shadow: inset 0 0 0 1px var(--blue); color:', self.styles)
def test_duration_is_removed_only_from_the_top_overview(self) -> None:
app = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")