Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1244-delivery-header.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The delivery header prints its "as of" time once while today is still running: the partial-day hint now follows the single time instead of repeating it from a second clock rendering, in both English and Chinese. A day whose runs the host could only count by their total now shows the same red share in the header row as the point drawn on the line beside it, instead of a dash next to a dot ([#1244](https://github.com/ThinkFlowLab/FlyBot/issues/1244)).
13 changes: 9 additions & 4 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1384,7 +1384,7 @@ var HOST_ZH = {
"{label} over the last 7 days": "{label}近 7 天",
"Last 7 days": "近 7 天",
"so far": "截至现在",
"today is partial · as of {time}": "今天为部分日 · 截至 {time}",
"today is partial": "今天为部分日",
"* {count} of 7 days had data": "* 7 天中 {count} 天有数",
"* {count} of {days} days had data": "* {days} 天中 {count} 天有数",
"CI red share": "CI 红占比",
Expand Down Expand Up @@ -8346,7 +8346,11 @@ function metricRows(snapshot) {
const today = snapshot?.today ?? {};
const week = snapshot?.week ?? {};
const ci = today.ci ?? null;
const share = (entry) => entry && Number.isFinite(entry.counted) && entry.counted > 0 ? `${Math.round(entry.failed / entry.counted * 100)}%` : "—";
const share = (entry) => {
if (!entry || !Number.isFinite(entry.failed)) return "—";
const denominator = Number.isFinite(entry.counted) && entry.counted > 0 ? entry.counted : Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null;
return denominator === null ? "—" : `${Math.round(entry.failed / denominator * 100)}%`;
};
const count = (value) => value === null || value === void 0 ? "—" : String(value);
const rows = [
{ label: t("New issues"), key: "newIssues" },
Expand Down Expand Up @@ -8512,6 +8516,7 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe =
const repoName = payload.repo ?? repo ?? null;
const repoSource = payload.repoSource ?? null;
const sourceText = repoSourceText(repoSource);
const partial = state !== "loading" && todayPartial(metricsPayload.daily);
return (0, import_react15.createElement)(
"header",
{ className: "bt-del-head" },
Expand Down Expand Up @@ -8545,8 +8550,8 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe =
(0, import_react15.createElement)(
"p",
{ className: "bt-del-asof" },
state === "loading" ? loadingText(payload.elapsedMs) : t("as of {time}", { time: timeOf(payload.asOf) }),
state !== "loading" && todayPartial(metricsPayload.daily) ? ` · ${t("today is partial · as of {time}", { time: clockOf(payload.asOf) })}` : "",
state === "loading" ? loadingText(payload.elapsedMs) : t("as of {time}", { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }),
partial ? ` · ${t("today is partial")}` : "",
payload.refreshing ? ` · ${t("refreshing…")}` : "",
error ? ` · ${hintFor(error, { repo })}` : "",
onRetry ? (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-del-retry", onClick: onRetry }, t("Retry")) : null,
Expand Down
21 changes: 16 additions & 5 deletions core/src/client/delivery-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,16 @@ export function metricRows(snapshot) {
const week = snapshot?.week ?? {}
const ci = today.ci ?? null
// The CI share is a ratio of the runs that counted, so it is written as a percentage and is
// never summed; a window with nothing counted reads '—', not 0%.
const share = (entry) => (entry && Number.isFinite(entry.counted) && entry.counted > 0
? `${Math.round((entry.failed / entry.counted) * 100)}%` : '—')
// never summed. A day the host could only count by `total` still has a denominator — the one the
// 7-day line beside this row draws — so the two halves read the same day the same way (#1244);
// only a window with no runs at all reads '—', never 0%.
const share = (entry) => {
if (!entry || !Number.isFinite(entry.failed)) return '—'
const denominator = Number.isFinite(entry.counted) && entry.counted > 0
? entry.counted
: (Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null)
return denominator === null ? '—' : `${Math.round((entry.failed / denominator) * 100)}%`
}
const count = (value) => (value === null || value === undefined ? '—' : String(value))
const rows = [
{ label: t('New issues'), key: 'newIssues' },
Expand Down Expand Up @@ -293,6 +300,10 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on
const repoName = payload.repo ?? repo ?? null
const repoSource = payload.repoSource ?? null
const sourceText = repoSourceText(repoSource)
// One time, one source (#1244): while today is still running the hint follows the time, so the
// paragraph prints the zone-clocked time once instead of repeating a local-clock rendering of the
// same instant — the two can disagree, not merely repeat.
const partial = state !== 'loading' && todayPartial(metricsPayload.daily)
return h('header', { className: 'bt-del-head' },
// Whose numbers these are, and where the name came from (#1250): a package default that looks
// like a fact is what showed one instance another repository's board.
Expand All @@ -312,8 +323,8 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on
}), open.medianIssueAgeMs !== null && open.medianIssueAgeMs !== undefined
? ` · ${t('median open age {age}', { age: duration(open.medianIssueAgeMs) })}` : '')),
h('p', { className: 'bt-del-asof' },
state === 'loading' ? loadingText(payload.elapsedMs) : t('as of {time}', { time: timeOf(payload.asOf) }),
state !== 'loading' && todayPartial(metricsPayload.daily) ? ` · ${t('today is partial · as of {time}', { time: clockOf(payload.asOf) })}` : '',
state === 'loading' ? loadingText(payload.elapsedMs) : t('as of {time}', { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }),
partial ? ` · ${t('today is partial')}` : '',
payload.refreshing ? ` · ${t('refreshing…')}` : '',
error ? ` · ${hintFor(error, { repo })}` : '',
onRetry ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-del-retry', onClick: onRetry }, t('Retry')) : null,
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1337,7 +1337,7 @@ const HOST_ZH = {
'{label} over the last 7 days': '{label}近 7 天',
'Last 7 days': '近 7 天',
'so far': '截至现在',
'today is partial · as of {time}': '今天为部分日 · 截至 {time}',
'today is partial': '今天为部分日',
'* {count} of 7 days had data': '* 7 天中 {count} 天有数',
'* {count} of {days} days had data': '* {days} 天中 {count} 天有数',
'CI red share': 'CI 红占比',
Expand Down
13 changes: 13 additions & 0 deletions core/test/delivery-chart.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,19 @@ test('#1212: the CI metric is the red share of the runs that counted', () => {
assert.equal(knownDays(daily, 'ci'), 6)
})

test('#1244: a day counted only by total still draws the share the header row reads', () => {
// `counted: 0` means the host could not say which runs were superseded, not that the day had
// nothing to count: the line draws `failed / total`, and the header row now reads the same
// denominator instead of printing '—' beside a dot.
const countedByTotal = { date: '2026-10-26', partial: true, ci: { failed: 1, counted: 0, total: 5 } }
assert.equal(dayValue(countedByTotal, 'ci'), 0.2)
const geo = chartGeometry([countedByTotal], 'ci')
assert.equal(geo.dots[0].state, 'partial', 'today is still running, and it is still drawn')
assert.equal(geo.dots[0].title, '2026-10-26 · 20%')
// Both columns empty is still unknown: neither a dot nor a share.
assert.equal(dayValue({ date: '2026-10-26', ci: { failed: 0, counted: 0, total: 0 } }, 'ci'), null)
})

test('#1212: the component draws a 168×28 svg with a state and a title on every point', () => {
const node = DeliveryChart({ daily, metricKey: 'newIssues', label: 'New issues' })
assert.equal(node.type, 'svg')
Expand Down
87 changes: 86 additions & 1 deletion core/test/delivery-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ const mount = (Component, props) => {
return { draw, rerender: draw }
}
const {
createPoller, DELIVERY_COLUMNS, DeliveryBoard, badgeLabel, duration, metricRows, pollDelay, retryCountdown,
createPoller, DELIVERY_COLUMNS, DeliveryBoard, DeliveryHeader, badgeLabel, duration, metricRows, pollDelay, retryCountdown,
loadingText, staleText, hintFor, missingText, probeText,
} = module.exports

Expand Down Expand Up @@ -207,6 +207,91 @@ test('the CI red share reaches the page as a line, and the medians draw none (#1
}
})

test('a day the host could only count by total reads the same share in the row as on the line (#1244)', () => {
// The header row required `counted`, so a day with `counted: 0, total: 5` printed '—' while the
// 7-day line beside it drew a dot from `total`: one day, two answers. The row now uses the same
// denominator the chart draws.
const countedByTotal = { date: '2026-10-25', partial: true, newIssues: 3, closedIssues: 1, newPullRequests: 0, mergedPullRequests: 0, ci: { failed: 1, counted: 0, total: 5 } }
const metrics = {
...snapshot.metrics,
today: { ...snapshot.metrics.today, ci: countedByTotal.ci },
daily: [countedByTotal],
}
const rows = metricRows(metrics)
assert.equal(rows.rows[4].today, '20%', 'one red run of five is 20%, not unknown')
// The denominator the chart uses for that same day (delivery-chart.test.js pins the number).
assert.equal(countedByTotal.ci.failed / countedByTotal.ci.total, 0.2)
// A window with no runs at all still has no share: unknown is not 0%, and it is not a dot either.
assert.equal(metricRows({ ...metrics, today: { ...snapshot.metrics.today, ci: { failed: 0, counted: 0, total: 0 } } }).rows[4].today, '—')
})

test('a partial day prints its time once, in the task zone, instead of twice (#1244)', async () => {
// #1244: the paragraph printed `as of {timeOf}` and then appended a hint carrying its own
// `clockOf` of the same instant — the time twice, and from two different renderings. The hint now
// follows the single time, and that time is the hint's own zone rendering.
const daily = [
{ date: '2026-10-19', newIssues: 1, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 0, ci: { failed: 0, counted: 10 } },
{ date: '2026-10-20', newIssues: 2, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 2, counted: 10 } },
{ date: '2026-10-21', newIssues: 0, closedIssues: 3, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 1, counted: 4 } },
{ date: '2026-10-22', newIssues: 4, closedIssues: 2, newPullRequests: 0, mergedPullRequests: 2, ci: { failed: null, counted: null } },
{ date: '2026-10-23', newIssues: 3, closedIssues: 3, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 0, counted: 8 } },
{ date: '2026-10-24', newIssues: 5, closedIssues: 4, newPullRequests: 3, mergedPullRequests: 2, ci: { failed: 3, counted: 12 } },
{ date: '2026-10-25', partial: true, newIssues: 4, closedIssues: 2, newPullRequests: 5, mergedPullRequests: 3, ci: { failed: 2, counted: 10 } },
]
const snapshotWith = dailySeries => ({ ...snapshot, metrics: { ...snapshot.metrics, daily: dailySeries } })
const asOfText = (Component, input) => {
const paragraph = walk(Component({ snapshot: input })).find(node => node.props?.className === 'bt-del-asof')
assert.ok(paragraph, 'the header carries an as-of line')
return text(paragraph).replace(/\s+/g, ' ').trim()
}
const times = words => [...words.matchAll(/\d{1,2}:\d{2}/g)].length

const english = asOfText(DeliveryHeader, snapshotWith(daily))
assert.equal(times(english), 1, `one time string, got: ${english}`)
assert.match(english, /today is partial/)
// The same paragraph in Chinese, through the same bundled module: the duplicate 截至 is gone too.
const { DeliveryHeader: DeliveryHeaderZh, installLocale } = await build({
stdin: {
contents: `export { DeliveryHeader } from ${JSON.stringify(fileURLToPath(new URL('../src/client/delivery-view.js', import.meta.url)))}\nexport { installLocale } from ${JSON.stringify(fileURLToPath(new URL('../src/client/i18n.js', import.meta.url)))}`,
resolveDir: fileURLToPath(new URL('../src/client/', import.meta.url)),
loader: 'js',
},
bundle: true, format: 'cjs', platform: 'node', write: false, external: ['react'],
}).then(async out => {
const module = { exports: {} }
runInNewContext(out.outputFiles[0].text, {
module, exports: module.exports, setTimeout, clearTimeout, Date: FakeDate, Promise, Number, Math, String, Array, Object,
setInterval: timers.setInterval, clearInterval: timers.clearInterval,
require: (name) => {
if (name !== 'react') throw new Error(`no platform module ${name}`)
return reactStub
},
})
return module.exports
})
const dictionaries = new Map()
const off = installLocale({
register: (namespace, locale, dict) => { dictionaries.set(`${namespace}:${locale}`, dict); return () => dictionaries.delete(`${namespace}:${locale}`) },
bind: namespace => (key, params) => {
const words = dictionaries.get(`${namespace}:zh`)?.[key] ?? key
return params ? words.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : words
},
getLocale: () => ({ active: 'zh' }),
})
try {
const chinese = asOfText(DeliveryHeaderZh, snapshotWith(daily))
assert.equal(times(chinese), 1, `one time string, got: ${chinese}`)
assert.match(chinese, /今天为部分日/)
assert.equal((chinese.match(/截至/g) ?? []).length, 1, 'the hint no longer repeats 截至')
} finally {
off()
}
// A finished day keeps the local-clock rendering and gains no hint at all.
const finished = asOfText(DeliveryHeader, snapshotWith(daily.map(day => ({ ...day, partial: false }))))
assert.equal(times(finished), 1)
assert.equal(finished.includes('today is partial'), false)
})

test('the board renders all five columns and a card with labels, owner and link (#1173)', () => {
const board = DeliveryBoard({ snapshot: { ...snapshot, columns: snapshot.columns }, message: '' })
const sections = walk(board).filter(node => node.type === 'section' && node.props?.['data-column'])
Expand Down
Loading
Loading