feat(qaops): 增加区域与全员提问排行及五十人分页
build / build (push) Successful in 1m6s

保留部门树并复用同一统计快照;区域内去重,跨区域和未归属单列,不改配置或主归属。按提问次数降序,保留零提问人员,每页50人。

验证:401项前端测试与类型检查通过,排序、区域去重、跨区域、零使用和123人三页边界红绿验证通过。
同镜像、正式网页和部署尚未验收。
This commit is contained in:
姚章浩
2026-10-21 18:06:30 +08:00
parent 99d6109bef
commit 4e655fb977
8 changed files with 563 additions and 59 deletions
+10
View File
@@ -76,6 +76,16 @@
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯,
> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。
## 开发快照 snapshot/v0.1.46 - 2026-10-21
### Added
- 区域统计新增“按部门/按区域/按人”分析切换;按部门保留原树形与直属人员。按区域查看已保存区域内人员,按人查看完整去重名册,均按提问次数降序并保留零提问人员,固定50人/页与页码选择,展示区域、所属部门、次数和最近提问时间。
### Changed
- 新分析复用同一累计/自然月/自然周快照,不额外创建模型或聚合作业;同区域多部门人员只列一次,跨区域及未归属者单列待核对,不自动选主区域。分析分页与切换只读,不改变原问答、区域映射、部门汇总、已保存周报及外部发送闸门。
## 开发快照 snapshot/v0.1.45 - 2026-10-21
### Added
+2
View File
@@ -6,6 +6,8 @@ import { departmentStatisticsFixture } from './qaDepartmentStatistics.fixture'
it('offers all, natural-month and natural-week scopes and never switches the selected-department action to a second mapping page', () => {
const html = renderToStaticMarkup(<QARegionalStatistics />)
for (const label of ['按累计统计', '按自然月统计', '按自然周统计', '绑定选中部门']) expect(html).toContain(label)
for (const label of ['按部门', '按区域', '按人']) expect(html).toContain('>' + label + '<')
expect(html).toContain('aria-label="分析维度"')
expect(html).not.toContain('设置选中部门的区域')
expect(html).not.toContain('返回部门统计')
})
+87 -59
View File
@@ -10,6 +10,8 @@ import { currentQAWeekValue } from './qaWeeks'
import { bindOrganizationDepartments } from './qaOrg'
import type { RegionDefinition } from './qaOrg'
import { QARegions } from './QARegions'
import { PeopleAnalysisView } from './QAPeopleAnalysis'
import { departmentTime } from './qaPeopleAnalysis'
import './qaDepartmentStatistics.css'
import { QAPeriodInput } from './QAPeriodInput'
export interface DepartmentStatisticsViewProps {
@@ -21,16 +23,7 @@ export interface DepartmentStatisticsViewProps {
onSelect?: (ids: string[]) => void
onCollapse?: (id: string) => void
}
export const departmentTime = (value: string) =>
new Date(value).toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
export { departmentTime } from './qaPeopleAnalysis'
export function defaultStatisticsCollapsed(data: DepartmentStatisticsSnapshot): Set<string> {
const direct = new Set(data.people.flatMap((p) => p.department_ids))
return new Set(
@@ -267,6 +260,7 @@ export function DepartmentRegionBinding({
}
const currentWeekValue = currentQAWeekValue
export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {}) {
const [analysisMode, setAnalysisMode] = useState<'departments' | 'regions' | 'people'>('departments')
const [period, setPeriod] = useState<'all' | 'month' | 'week'>('all'),
[month, setMonth] = useState(currentMonth),
[week, setWeek] = useState(currentWeekValue),
@@ -390,6 +384,19 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {})
</button>
</div>
</header>
<nav className="qa-analysis-switcher" aria-label="分析维度">
{(
[
['departments', '按部门'],
['regions', '按区域'],
['people', '按人'],
] as const
).map(([mode, label]) => (
<button key={mode} type="button" aria-pressed={analysisMode === mode} onClick={() => setAnalysisMode(mode)}>
{label}
</button>
))}
</nav>
<aside
className="qa-region-settings-panel"
hidden={!settings}
@@ -441,36 +448,40 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {})
onChange={setWeek}
/>
) : null}
<label>
部门
<input
type="search"
aria-label="搜索统计部门"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索部门或完整路径"
/>
</label>
<label>
区域
<select aria-label="筛选统计区域" value={region} onChange={(e) => setRegion(e.target.value)}>
<option value="">全部区域</option>
{data?.regions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
<DepartmentRegionBinding
regions={data?.regions ?? []}
selectedCount={selected.length}
disabled={busy || binding || settings || catalogDirty}
onBind={(id) => void bind(id)}
/>
<button type="button" disabled={!selected.length} onClick={() => setSelected([])}>
取消勾选
</button>
{analysisMode === 'departments' ? (
<>
<label>
部门
<input
type="search"
aria-label="搜索统计部门"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索部门或完整路径"
/>
</label>
<label>
区域
<select aria-label="筛选统计区域" value={region} onChange={(e) => setRegion(e.target.value)}>
<option value="">全部区域</option>
{data?.regions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
<DepartmentRegionBinding
regions={data?.regions ?? []}
selectedCount={selected.length}
disabled={busy || binding || settings || catalogDirty}
onBind={(id) => void bind(id)}
/>
<button type="button" disabled={!selected.length} onClick={() => setSelected([])}>
取消勾选
</button>
</>
) : null}
</div>
{catalogDirty ? <p className="qa-ops-muted">区域设置有未保存修改,请先保存或放弃后再绑定部门。</p> : null}
{bindingError ? (
@@ -499,25 +510,42 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {})
) : null}
{data ? (
<>
<DepartmentStatisticsView
data={data}
selected={selected}
collapsed={collapsed}
search={search}
region={region}
onSelect={setSelected}
onCollapse={(id) =>
setCollapsed((old) => {
const next = new Set(old)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
/>
<p className="qa-department-note">
部门汇总含全部子部门并按稳定身份去重;展开仅列本部门直属人员,兼属人数为当前通讯录直接关联的不同部门总数,不包含自动继承的上级。个人访问次数跨兼属部门展示同一数值,不重复累计到部门汇总;收起及区域筛选不改变统计。访问为已入总表的提问事件。
</p>
{analysisMode === 'departments' ? (
<DepartmentStatisticsView
data={data}
selected={selected}
collapsed={collapsed}
search={search}
region={region}
onSelect={setSelected}
onCollapse={(id) =>
setCollapsed((old) => {
const next = new Set(old)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
/>
) : (
<PeopleAnalysisView
key={[
analysisMode,
data.period,
data.month,
data.week,
data.organization.directory_id,
data.organization.revision_id,
].join(':')}
data={data}
mode={analysisMode}
/>
)}
{analysisMode === 'departments' ? (
<p className="qa-department-note">
部门汇总含全部子部门并按稳定身份去重;展开仅列本部门直属人员,兼属人数为当前通讯录直接关联的不同部门总数,不包含自动继承的上级。个人访问次数跨兼属部门展示同一数值,不重复累计到部门汇总;收起及区域筛选不改变统计。访问为已入总表的提问事件。
</p>
) : null}
{data.unmapped_visits > 0 ? (
<p className="qa-department-note">
另有 {data.unmapped_visits.toLocaleString('zh-CN')} 次提问未能归入当前通讯录部门,未虚构归属。
+41
View File
@@ -0,0 +1,41 @@
import { it, expect } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { PeopleAnalysisView, PeopleRankingTable } from './QAPeopleAnalysis'
import { peopleAnalysis } from './qaPeopleAnalysis'
import { parseDepartmentStatistics } from './qaDepartmentStatistics'
import { departmentStatisticsFixture } from './qaDepartmentStatistics.fixture'
const fixture = () =>
parseDepartmentStatistics(structuredClone(departmentStatisticsFixture), { period: 'all' }).snapshot!
it('renders at most fifty ranked people with actual question counts and no department write controls', () => {
const template = peopleAnalysis(fixture()).people[0]
const rows = Array.from({ length: 123 }, (_, i) => ({
...template,
key: 'person-' + i.toString(16).padStart(64, '0'),
visits: 122 - i,
}))
const html = renderToStaticMarkup(<PeopleRankingTable people={rows} page={2} onPage={() => {}} label="全体人员" />)
expect(html.match(/data-ranking-person-key=/g)).toHaveLength(50)
expect(html).toContain('data-ranking="51"')
expect(html).toContain('data-ranking="100"')
expect(html).not.toContain('data-ranking="101"')
expect(html).toContain('50 人/页')
expect(html).toContain('上一页')
expect(html).toContain('下一页')
expect(html).not.toContain('checkbox')
expect(html).not.toContain('绑定选中部门')
})
it('region analysis lists the saved region members once and all-person analysis retains unassigned and zero-use people', () => {
const data = fixture()
const region = renderToStaticMarkup(<PeopleAnalysisView data={data} mode="regions" />)
expect(region).toContain('按区域查看人员')
expect(region.match(/data-ranking-person-key=/g)).toHaveLength(2)
expect(region).toContain('Fixture Alpha')
expect(region).not.toContain('Fixture Beta')
expect(region).toContain('跨区域待核对')
expect(region).toContain('未归属')
const all = renderToStaticMarkup(<PeopleAnalysisView data={data} mode="people" />)
expect(all.match(/data-ranking-person-key=/g)).toHaveLength(3)
expect(all).toContain('未命名人员')
expect(all).toContain('Fixture Beta')
expect(all.indexOf('Fixture Beta')).toBeLessThan(all.indexOf('Fixture Alpha'))
})
+161
View File
@@ -0,0 +1,161 @@
import { useMemo, useState } from 'react'
import type { DepartmentStatisticsSnapshot } from './qaDepartmentStatistics'
import { peopleAnalysis, peoplePage, PEOPLE_PAGE_SIZE, departmentTime } from './qaPeopleAnalysis'
import type { PersonRankingRow } from './qaPeopleAnalysis'
export function PeopleRankingTable({
people,
page,
onPage,
label,
}: {
people: PersonRankingRow[]
page: number
onPage: (n: number) => void
label: string
}) {
const window = peoplePage(people, page)
return (
<section
className="qa-people-ranking"
aria-label={label}
data-ranking-total={window.total}
data-ranking-page={window.page}
>
<div className="qa-department-table-scroll" tabIndex={0} aria-label="人员排行表格滚动区">
<table className="qa-department-table qa-people-ranking-table">
<colgroup>
<col style={{ width: '6%' }} />
<col style={{ width: '19%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '29%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '20%' }} />
</colgroup>
<thead>
<tr>
<th>排名</th>
<th>人员</th>
<th>区域</th>
<th>所属部门</th>
<th>提问次数</th>
<th>最近提问时间</th>
</tr>
</thead>
<tbody>
{window.items.map((person, i) => (
<tr
key={person.key}
data-ranking-person-key={person.key}
data-ranking={window.start + i + 1}
data-visits={person.visits}
data-person-region={person.group_key}
>
<td>{window.start + i + 1}</td>
<td className="qa-people-name-cell">
<span>{person.name || '未命名人员'}</span>
{person.department_ids.length > 1 ? (
<small className="qa-people-multi">兼属 {person.department_ids.length} 部门</small>
) : null}
</td>
<td className="qa-people-region-cell">{person.region_label}</td>
<td className="qa-people-paths">
{person.department_paths.length ? (
<details>
<summary title={person.department_paths.map((d) => d.path).join(';')}>
{person.department_paths.length} 个直属部门
</summary>
<ul>
{person.department_paths.map((d) => (
<li key={d.id}>{d.path}</li>
))}
</ul>
</details>
) : (
'未关联部门'
)}
</td>
<td className="qa-people-question-count">{person.visits.toLocaleString('zh-CN')}</td>
<td>{person.last_asked_at ? departmentTime(person.last_asked_at) : '—'}</td>
</tr>
))}
</tbody>
</table>
{!window.total ? <p className="qa-ops-empty">此范围暂无人员,未将其他区域人员混入。</p> : null}
</div>
<nav className="qa-people-pagination" aria-label="人员分页">
<span>
{PEOPLE_PAGE_SIZE} 人/页 · 共 {window.total.toLocaleString('zh-CN')} 人 · 显示{' '}
{window.total ? window.start + 1 : 0}—{window.end}
</span>
<div>
<button type="button" disabled={window.page === 1} onClick={() => onPage(window.page - 1)}>
上一页
</button>
<label>
第{' '}
<select aria-label="人员页码" value={window.page} onChange={(e) => onPage(Number(e.target.value))}>
{Array.from({ length: window.pages }, (_, i) => (
<option value={i + 1} key={i + 1}>
{i + 1}
</option>
))}
</select>{' '}
/ {window.pages} 页
</label>
<button type="button" disabled={window.page === window.pages} onClick={() => onPage(window.page + 1)}>
下一页
</button>
</div>
</nav>
</section>
)
}
export function PeopleAnalysisView({ data, mode }: { data: DepartmentStatisticsSnapshot; mode: 'regions' | 'people' }) {
const analysis = useMemo(() => peopleAnalysis(data), [data]),
[selected, setSelected] = useState(''),
[page, setPage] = useState(1)
const group =
analysis.groups.find((g) => g.key === selected) ??
analysis.groups.find((g) => g.people.length) ??
analysis.groups[0]
const people = mode === 'people' ? analysis.people : group.people
return (
<section className="qa-people-analysis" data-analysis-mode={mode} data-generated-at={data.generated_at}>
<div className="qa-people-analysis-heading">
{mode === 'regions' ? (
<label>
按区域查看人员{' '}
<select
aria-label="人员分析区域"
value={group.key}
onChange={(e) => {
setSelected(e.target.value)
setPage(1)
}}
>
{analysis.groups.map((g) => (
<option key={g.key} value={g.key}>
{g.name}({g.people.length} 人)
</option>
))}
</select>
</label>
) : (
<h4>全体人员</h4>
)}
<span>按提问次数从高到低 · 同一人员只列一次 · 保留零提问人员</span>
</div>
<PeopleRankingTable
people={people}
page={page}
onPage={setPage}
label={mode === 'regions' ? '区域人员排行' : '全体人员排行'}
/>
<p className="qa-department-note">
沿用当前已同步通讯录及已保存区域配置;同一区域多部门人员去重,跨区域人员单列“跨区域待核对”,未配置者列“未归属”,不擅自裁决主归属或生成绩效结论。
</p>
</section>
)
}
+124
View File
@@ -1,3 +1,127 @@
.qa-analysis-switcher {
display: inline-flex;
gap: 4px;
padding: 4px;
margin: 0 0 8px;
border: 1px solid #e5ebf2;
border-radius: 6px;
background: #f1f5f9;
}
.qa-analysis-switcher button {
border: 0;
background: transparent;
color: #64748b;
padding: 7px 18px;
border-radius: 4px;
}
.qa-analysis-switcher button[aria-pressed='true'] {
background: #fff;
color: #2563eb;
box-shadow: 0 1px 3px #33415514;
font-weight: 600;
}
.qa-analysis-switcher button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 1px;
}
.qa-people-analysis {
min-width: 0;
}
.qa-people-analysis-heading {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin: 0 0 10px;
}
.qa-people-analysis-heading h4 {
margin: 0;
font-size: 15px;
}
.qa-people-analysis-heading label {
display: flex;
gap: 8px;
align-items: center;
font-size: 13px;
}
.qa-people-analysis-heading select {
max-width: 100%;
min-height: 32px;
}
.qa-people-analysis-heading > span {
color: #718096;
font-size: 12px;
}
.qa-people-ranking-table {
min-width: 1000px;
}
.qa-people-ranking-table .qa-people-name-cell,
.qa-people-ranking-table .qa-people-region-cell,
.qa-people-ranking-table .qa-people-paths {
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.6;
}
.qa-people-multi {
display: inline-block;
color: #64748b;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 0 5px;
margin-left: 6px;
font-size: 11px;
font-weight: 400;
}
.qa-people-paths summary {
cursor: pointer;
color: #536c88;
}
.qa-people-paths ul {
padding-left: 16px;
margin: 6px 0 0;
}
.qa-people-question-count {
font-weight: 600;
}
.qa-people-pagination {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px 0 0;
color: #64748b;
font-size: 12px;
}
.qa-people-pagination > div {
display: flex;
gap: 10px;
align-items: center;
}
.qa-people-pagination select {
min-height: 30px;
}
@media (max-width: 680px) {
.qa-people-analysis-heading {
align-items: flex-start;
}
.qa-people-analysis-heading label {
flex-wrap: wrap;
}
.qa-people-analysis-heading select {
max-width: 280px;
}
.qa-people-pagination > div {
width: 100%;
justify-content: space-between;
}
.qa-analysis-switcher button {
padding: 7px 15px;
}
}
.qa-regional-statistics {
min-width: 0;
color: #334155;
+60
View File
@@ -0,0 +1,60 @@
import { it, expect } from 'vitest'
import { peopleAnalysis, peoplePage } from './qaPeopleAnalysis'
import { parseDepartmentStatistics } from './qaDepartmentStatistics'
import { departmentStatisticsFixture } from './qaDepartmentStatistics.fixture'
const fixture = () =>
parseDepartmentStatistics(structuredClone(departmentStatisticsFixture), { period: 'all' }).snapshot!
it('ranks every unique roster person by questions descending including zero use without mutating the snapshot', () => {
const data = fixture(),
before = JSON.stringify(data),
result = peopleAnalysis(data)
expect(result.people.map((p) => p.visits)).toEqual([3, 2, 0])
expect(new Set(result.people.map((p) => p.key)).size).toBe(3)
expect(result.people.find((p) => p.visits === 0)?.name).toBe('')
expect(JSON.stringify(data)).toBe(before)
})
it('groups same-region memberships once and separates cross-region and unassigned without choosing a primary', () => {
const data = fixture()
data.regions.push({ id: 'industrial', name: 'Industrial' }, { id: 'empty', name: 'Empty' })
data.rows.find((d) => d.id === 'root')!.region = 'industrial'
const result = peopleAnalysis(data),
groups = new Map(result.groups.map((g) => [g.key, g]))
expect(groups.get('region:edu')?.people.map((p) => p.visits)).toEqual([0])
expect(groups.get('region:industrial')?.people.map((p) => p.visits)).toEqual([3])
expect(groups.get('state:cross_region')?.people.map((p) => p.visits)).toEqual([2])
expect(groups.get('region:empty')?.people).toEqual([])
expect(result.people.find((p) => p.visits === 2)?.region_label).toContain('跨区域待核对')
expect(
result.groups
.flatMap((g) => g.people)
.map((p) => p.key)
.sort()
).toEqual(data.people.map((p) => p.key).sort())
const initial = peopleAnalysis(fixture())
expect(initial.groups.find((g) => g.key === 'region:edu')?.people).toHaveLength(2)
expect(initial.groups.find((g) => g.key === 'state:unassigned')?.people).toHaveLength(1)
})
it('paginates all people at exactly fifty with no gaps, correct final page and deterministic ties', () => {
const template = peopleAnalysis(fixture()).people[0]
const data = Array.from({ length: 123 }, (_, i) => ({
...template,
key: 'person-' + i.toString(16).padStart(64, '0'),
visits: 122 - i,
}))
const pages = [1, 2, 3].map((n) => peoplePage(data, n))
expect(pages.map((p) => p.items.length)).toEqual([50, 50, 23])
expect(pages.map((p) => p.start)).toEqual([0, 50, 100])
expect(pages.flatMap((p) => p.items.map((r) => r.key))).toEqual(data.map((r) => r.key))
expect(peoplePage(data, 999).page).toBe(3)
expect(peoplePage(data, -1).page).toBe(1)
expect(peoplePage(data, NaN).page).toBe(1)
expect(peoplePage([], 1)).toMatchObject({ items: [], total: 0, start: 0, end: 0, page: 1, pages: 1 })
const same = fixture()
same.people.forEach((p) => {
p.name = '同名'
p.visits = 1
})
const first = peopleAnalysis(same).people.map((p) => p.key)
same.people.reverse()
expect(peopleAnalysis(same).people.map((p) => p.key)).toEqual(first)
})
+78
View File
@@ -0,0 +1,78 @@
import type { DepartmentPersonStatistic, DepartmentStatisticsSnapshot } from './qaDepartmentStatistics'
export const departmentTime = (value: string) =>
new Date(value).toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
export const PEOPLE_PAGE_SIZE = 50
export type PersonRankingRow = DepartmentPersonStatistic & {
region_ids: string[]
region_state: 'matched' | 'cross_region' | 'unassigned'
group_key: string
region_label: string
department_paths: Array<{ id: string; path: string }>
}
export type PeopleAnalysisGroup = { key: string; name: string; people: PersonRankingRow[]; visits: number }
export function peopleAnalysis(data: DepartmentStatisticsSnapshot): {
people: PersonRankingRow[]
groups: PeopleAnalysisGroup[]
} {
const nodes = new Map(data.rows.map((d) => [d.id, d]))
const people: PersonRankingRow[] = data.people
.map((person) => ({
...person,
department_ids: [...person.department_ids],
region_ids: [],
region_state: 'unassigned' as const,
group_key: 'state:unassigned',
region_label: '未归属',
department_paths: person.department_ids.map((id) => {
const names: string[] = []
let current = id
while (current) {
const d = nodes.get(current)!
names.unshift(d.name)
current = d.parent_id
}
return { id, path: names.join('/') }
}),
}))
.sort((a, b) => b.visits - a.visits || a.name.localeCompare(b.name, 'zh-CN') || a.key.localeCompare(b.key))
const labels = new Map(data.regions.map((r) => [r.id, r.name]))
const groups: PeopleAnalysisGroup[] = [
...data.regions.map((r) => ({ key: 'region:' + r.id, name: r.name, people: [] as PersonRankingRow[], visits: 0 })),
{ key: 'state:cross_region', name: '跨区域待核对', people: [], visits: 0 },
{ key: 'state:unassigned', name: '未归属', people: [], visits: 0 },
]
const byKey = new Map(groups.map((g) => [g.key, g]))
for (const person of people) {
person.region_ids = [...new Set(person.department_ids.map((id) => nodes.get(id)!.region).filter(Boolean))].sort()
person.region_state =
person.region_ids.length === 1 ? 'matched' : person.region_ids.length > 1 ? 'cross_region' : 'unassigned'
person.group_key =
person.region_state === 'matched' ? 'region:' + person.region_ids[0] : 'state:' + person.region_state
person.region_label =
person.region_state === 'matched'
? labels.get(person.region_ids[0])!
: person.region_state === 'cross_region'
? '跨区域待核对(' + person.region_ids.map((id) => labels.get(id)).join('、') + ')'
: '未归属'
const group = byKey.get(person.group_key)!
group.people.push(person)
group.visits += person.visits
}
return { people, groups }
}
export function peoplePage(people: PersonRankingRow[], requested: number) {
const total = people.length,
pages = Math.max(1, Math.ceil(total / PEOPLE_PAGE_SIZE))
const page = Math.max(1, Math.min(pages, Number.isFinite(requested) ? Math.trunc(requested) : 1))
const start = (page - 1) * PEOPLE_PAGE_SIZE,
end = Math.min(total, start + PEOPLE_PAGE_SIZE)
return { items: people.slice(start, end), page, pages, total, start, end }
}