fix: add headroom to dashboard bar scales

This commit is contained in:
inman
2026-09-02 11:27:34 +08:00
parent ed456e6a5d
commit 63b387f679
5 changed files with 96 additions and 10 deletions

View File

@@ -0,0 +1,55 @@
# Task: Add dynamic nice scale to dashboard bars
## Identity
- Task ID: 20260902-dashboard-nice-scale-d8c31a
- Mode: Feature
- Branch: codex/20260902-dashboard-nice-scale-d8c31a-dashboard-nice-scale-d8c31a
- Worktree: /Users/inmanx/Documents/lwltAPI-dashboard-nice-scale-d8c31a
- Base commit: e530b7a3489c8f2be2f15f69de2149658aaa8ca3
- Owner: codex
- Status: Ready for integration
## Scope
- Replace max-item normalization in both dashboard rankings with an independent dynamic “nice” scale that leaves visible headroom above the leading bar.
- Keep row-as-bar presentation, descending operation-count order, exact operation/success labels, fixed layout, scrolling, and drill-through behavior unchanged.
- Update static asset cache versions and focused dashboard regression coverage.
## Intent And Constraints
- Choose readable count intervals from `1 / 2 / 5 / 10` steps over roughly five intervals, round the observed maximum upward, and advance one further interval when the maximum already lands exactly on a scale boundary.
- Expected reference cases include `43 → 50`, `359 → 400`, and exact-boundary `100 → 120`; empty data remains safe.
- Compute task-type and employee scales independently. Operation count alone controls bar length; success count remains text and no completion rate or percentage is introduced.
- Preserve leadership-only access, business-safe language, employee/task-type drill-through, and all task/ERP authorization boundaries.
- Do not change the dashboard API, restart or deploy services, mutate runtime data, access ERP, or introduce an organization concept.
## Outcome
- Added a reusable dynamic ranking scale that divides the observed maximum into roughly five readable intervals using `1 / 2 / 5 / 10` steps.
- The scale rounds upward and advances one additional interval when the maximum already lands exactly on a boundary, so the leading bar always keeps visible headroom.
- Reference values now resolve as `43 → 50`, `359 → 400`, `100 → 120`, `1 → 2`, and empty data → `1`.
- Task-type and employee rankings compute their scales independently. Existing descending operation-count order, row-as-bar rendering, exact operation/success counts, fixed panel layout, scrolling, and drill-through remain unchanged.
- Updated the static asset cache version and added executable regression assertions for the dynamic-scale reference cases.
- No API, authorization, runtime data, ERP, deployment, or service-restart behavior changed.
## Verification
- `node --check LianSyn-platform/app.js`: passed.
- Focused account/dashboard regression: 8/8 passed, including dynamic-scale cases `0 → 1`, `1 → 2`, `43 → 50`, `100 → 120`, and `359 → 400`.
- `node --run check:repo`: 10/10 passed.
- `node --run check`: passed.
- `node --run test:control-plane`: 153/153 passed.
- `node --run test:legacy`: 256/256 passed.
- `node --run build`: passed.
- `git diff --check`: passed.
- Isolated browser preview at `http://127.0.0.1:8892/operations-dashboard?nice-scale=1` used a non-persistent mock dashboard response and the actual feature source/CSS. It rendered task-type widths `86% / 54% / 52% / 48% / 48%` for totals `43 / 27 / 26 / 24 / 24`, and employee width `89.75%` for total `359`.
- Visual inspection confirmed both leading bars retain right-side headroom. DOM and interaction verification confirmed descending order, exact operation/success text, and task-type click-through selecting `order_delete`; no completion-rate or percentage label was shown.
## Follow-ups
- Integrate the isolated commit only after the active main-branch password-flow integration releases its ownership gate, then reload the standard 8786 dashboard for a final runtime check.
## Promotion Candidates
- None. This is a focused visual-scale correction within the accepted count-based ranking behavior and does not change product, authorization, API, or data semantics.

View File

@@ -1385,8 +1385,24 @@ function renderOperationsDashboardSummary() {
}
}
function operationsDashboardRankBar(total, maximum) {
const safeMaximum = Math.max(1, Number(maximum || 0));
function operationsDashboardRankScale(maximum) {
const numericMaximum = Number(maximum || 0);
const safeMaximum = Number.isFinite(numericMaximum) ? Math.max(0, numericMaximum) : 0;
if (safeMaximum <= 0) return 1;
const roughStep = safeMaximum / 5;
const magnitude = 10 ** Math.floor(Math.log10(Math.max(1, roughStep)));
const normalizedStep = roughStep / magnitude;
const niceStep = Math.max(
1,
(normalizedStep <= 1 ? 1 : normalizedStep <= 2 ? 2 : normalizedStep <= 5 ? 5 : 10) * magnitude
);
const roundedMaximum = Math.ceil(safeMaximum / niceStep) * niceStep;
return roundedMaximum <= safeMaximum ? roundedMaximum + niceStep : roundedMaximum;
}
function operationsDashboardRankBar(total, scaleMaximum) {
const safeMaximum = Math.max(1, Number(scaleMaximum || 0));
const safeTotal = Math.max(0, Number(total || 0));
const bar = el('span', 'operations-dashboard-rank-bar');
bar.setAttribute('aria-hidden', 'true');
@@ -1408,7 +1424,9 @@ function renderOperationsDashboardBusinesses() {
container.append(el('p', 'operations-dashboard-empty', '当前范围内还没有任务类型数据。'));
return;
}
const maximum = Math.max(...businesses.map((item) => Number(item.total || 0)), 1);
const scaleMaximum = operationsDashboardRankScale(
Math.max(...businesses.map((item) => Number(item.total || 0)), 0)
);
for (const [index, item] of businesses.entries()) {
const button = el('button', 'operations-dashboard-business-row');
button.type = 'button';
@@ -1418,7 +1436,7 @@ function renderOperationsDashboardBusinesses() {
el('span', 'operations-dashboard-rank-number', String(index + 1).padStart(2, '0')),
el('strong', 'operations-dashboard-rank-name', operationsDashboardBusinessLabel(item.label))
);
button.prepend(operationsDashboardRankBar(item.total, maximum));
button.prepend(operationsDashboardRankBar(item.total, scaleMaximum));
const metrics = el('span', 'operations-dashboard-rank-metrics');
metrics.append(
el('span', 'is-total', `操作 ${Number(item.total || 0)}`),
@@ -1444,7 +1462,9 @@ function renderOperationsDashboardUsers() {
container.append(el('p', 'operations-dashboard-empty', '当前范围内还没有人员工作数据。'));
return;
}
const maximum = Math.max(...users.map((item) => Number(item.total || 0)), 1);
const scaleMaximum = operationsDashboardRankScale(
Math.max(...users.map((item) => Number(item.total || 0)), 0)
);
for (const [index, item] of users.entries()) {
const button = el('button', 'operations-dashboard-user-row');
button.type = 'button';
@@ -1460,7 +1480,7 @@ function renderOperationsDashboardUsers() {
el('strong', '', item.actor.username),
el('small', '', accountRoleLabel(item.actor.role))
);
const bar = operationsDashboardRankBar(item.total, maximum);
const bar = operationsDashboardRankBar(item.total, scaleMaximum);
const metrics = el('div', 'operations-dashboard-user-metrics');
metrics.append(
el('span', 'is-total', `操作 ${Number(item.total || 0)}`),

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#edf2f4">
<title>AI操作台 · LianSyn-platform</title>
<link rel="stylesheet" href="styles.css?v=20260902-dashboard-bar-chart-1">
<link rel="stylesheet" href="styles.css?v=20260902-dashboard-nice-scale-1">
</head>
<body>
<main class="app-shell">
@@ -397,6 +397,6 @@
</section>
</main>
<script src="app.js?v=20260902-dashboard-bar-chart-1"></script>
<script src="app.js?v=20260902-dashboard-nice-scale-1"></script>
</body>
</html>

View File

@@ -248,10 +248,21 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
assert.match(app, /business_route_id/);
assert.match(app, /data-operations-status|dataset\.operationsStatus/);
assert.match(app, /data-operations-business|dataset\.operationsBusiness/);
assert.match(app, /function operationsDashboardRankScale/);
assert.match(app, /operationsDashboardRankBar/);
assert.match(app, /operations-dashboard-rank-bar/);
assert.match(app, /sort\(\(left, right\) => Number\(right\.total/);
assert.doesNotMatch(app, /完成率|operationsDashboardCompletionRate|operationsDashboardDays/);
const rankScaleSource = app.slice(
app.indexOf('function operationsDashboardRankScale('),
app.indexOf('function operationsDashboardRankBar(')
);
const rankScale = Function(`${rankScaleSource}\nreturn operationsDashboardRankScale;`)() as (maximum: number) => number;
assert.equal(rankScale(0), 1);
assert.equal(rankScale(1), 2);
assert.equal(rankScale(43), 50);
assert.equal(rankScale(100), 120);
assert.equal(rankScale(359), 400);
assert.match(app, /function operationsDashboardReadableResult/);
assert.match(app, /function operationsDashboardReadableInput/);
assert.match(app, /原始输入/);

View File

@@ -1225,8 +1225,8 @@ test('operator page has a login gate and uses the durable task API', async () =>
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
assert.match(index, /id="loginPanel"/);
assert.match(index, /id="workbench"[^>]*hidden/);
assert.match(index, /styles\.css\?v=20260902-dashboard-bar-chart-1/);
assert.match(index, /app\.js\?v=20260902-dashboard-bar-chart-1/);
assert.match(index, /styles\.css\?v=20260902-dashboard-nice-scale-1/);
assert.match(index, /app\.js\?v=20260902-dashboard-nice-scale-1/);
assert.match(index, /id="statusDetailsPopover"/);
assert.match(index, /id="statusDetailsRefresh"/);
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);