feat(v2.2): 完成高频读取热路径

This commit is contained in:
Script Generator
2026-07-03 11:53:25 +08:00
parent 5a90923031
commit 70460c3273
28 changed files with 3042 additions and 121 deletions

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildV22RequirementQuery } from './requirement-v22-query';
test('buildV22RequirementQuery creates a product-scoped query with server-side filters', () => {
const query = buildV22RequirementQuery({
selectedScope: { type: 'product', productId: 'product-1' },
projects: [],
statusFilter: 'adopted',
priorityFilter: 'P1',
typeFilter: 'feature',
versionFilter: 'version-1',
search: 'login',
dateSort: 'asc',
limit: 50,
cursor: 'req-1',
});
assert.deepEqual(query, {
productId: 'product-1',
status: 'adopted',
priority: 'P1',
type: 'feature',
versionId: 'version-1',
q: 'login',
sort: 'created_at_asc',
limit: 50,
cursor: 'req-1',
});
});
test('buildV22RequirementQuery resolves project scope to product and project partition keys', () => {
const query = buildV22RequirementQuery({
selectedScope: { type: 'project', projectId: 'project-1' },
projects: [{ id: 'project-1', productId: 'product-1' }],
statusFilter: 'all',
priorityFilter: 'all',
typeFilter: 'all',
versionFilter: 'all',
search: ' ',
dateSort: 'desc',
limit: 20,
});
assert.deepEqual(query, {
productId: 'product-1',
projectId: 'project-1',
sort: 'created_at_desc',
limit: 20,
});
});
test('buildV22RequirementQuery skips scopes that would require full scans or derived status filters', () => {
assert.equal(buildV22RequirementQuery({
selectedScope: { type: 'all' },
projects: [],
statusFilter: 'all',
priorityFilter: 'all',
typeFilter: 'all',
versionFilter: 'all',
search: '',
dateSort: 'desc',
limit: 20,
}), undefined);
assert.equal(buildV22RequirementQuery({
selectedScope: { type: 'product', productId: 'product-1' },
projects: [],
statusFilter: 'dev_completed',
priorityFilter: 'all',
typeFilter: 'all',
versionFilter: 'all',
search: '',
dateSort: 'desc',
limit: 20,
}), undefined);
assert.equal(buildV22RequirementQuery({
selectedScope: { type: 'project', projectId: 'missing-project' },
projects: [{ id: 'project-1', productId: 'product-1' }],
statusFilter: 'all',
priorityFilter: 'all',
typeFilter: 'all',
versionFilter: 'all',
search: '',
dateSort: 'desc',
limit: 20,
}), undefined);
});

View File

@@ -0,0 +1,73 @@
import type { RequirementScopeSelection } from './requirement-scope';
import type { RequirementDateSort } from './requirement-sort';
import type { V22RequirementsQuery } from './v22-api';
export interface RequirementV22ProjectRef {
id: string;
productId: string;
}
export interface BuildV22RequirementQueryInput {
selectedScope: RequirementScopeSelection;
projects: RequirementV22ProjectRef[];
statusFilter: string;
priorityFilter: string;
typeFilter: string;
versionFilter: string;
search: string;
dateSort: RequirementDateSort;
limit: number;
cursor?: string;
}
export function buildV22RequirementQuery(input: BuildV22RequirementQueryInput): V22RequirementsQuery | undefined {
if (input.statusFilter === 'dev_completed') return undefined;
const scoped = getPartitionScope(input.selectedScope, input.projects);
if (!scoped) return undefined;
return compactQuery({
...scoped,
status: input.statusFilter === 'all' ? undefined : input.statusFilter,
priority: input.priorityFilter === 'all' ? undefined : input.priorityFilter,
type: input.typeFilter === 'all' ? undefined : input.typeFilter,
versionId: input.versionFilter === 'all' ? undefined : input.versionFilter,
q: input.search.trim() || undefined,
sort: input.dateSort === 'asc' ? 'created_at_asc' : 'created_at_desc',
cursor: input.cursor,
limit: input.limit,
});
}
function getPartitionScope(
selectedScope: RequirementScopeSelection,
projects: RequirementV22ProjectRef[],
): Pick<V22RequirementsQuery, 'productId' | 'projectId'> | undefined {
if (selectedScope.type === 'product') {
const productId = selectedScope.productId.trim();
return productId ? { productId } : undefined;
}
if (selectedScope.type === 'project') {
const projectId = selectedScope.projectId.trim();
const project = projects.find((item) => item.id === projectId);
if (!project?.productId) return undefined;
return { productId: project.productId, projectId };
}
return undefined;
}
function compactQuery(query: V22RequirementsQuery): V22RequirementsQuery {
const result: V22RequirementsQuery = { productId: query.productId };
if (query.projectId) result.projectId = query.projectId;
if (query.versionId) result.versionId = query.versionId;
if (query.status) result.status = query.status;
if (query.priority) result.priority = query.priority;
if (query.type) result.type = query.type;
if (query.q) result.q = query.q;
if (query.sort) result.sort = query.sort;
if (query.cursor) result.cursor = query.cursor;
if (query.limit) result.limit = query.limit;
return result;
}

View File

@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { __resetApiAvailabilityForTests, resolveApiBase } from './api';
import {
loadV22RequirementsPage,
loadV22VersionDetailData,
loadV22WorkspaceData,
loadV22XiaobaoWarnings,
} from './v22-api';
test('V2.2 version detail API maps relation rows to existing frontend scope types', async () => {
const originalFetch = globalThis.fetch;
const apiBase = resolveApiBase();
const calls: Array<{ url: string; method: string }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, method: init?.method ?? 'GET' });
if (url === `${apiBase}/config/ai`) {
return jsonResponse({});
}
if (url === `${apiBase}/v2.2/versions/version-1/detail-data`) {
return jsonResponse({
version: {
id: 'version-1',
productId: 'product-1',
projectId: 'project-1',
name: 'Project A V1.0',
releaseDate: '2026-01-20T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
},
requirements: [{
id: 'req-1',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
code: 'REQ-001',
title: 'Login',
description: 'Password login',
status: 'adopted',
priority: 1,
type: 'feature',
sourceType: 'customer',
sourceTarget: 'ACME',
platform: 'web, ios',
creatorId: 'member-1',
createdAt: '2026-01-01T00:00:00.000Z',
}],
versionPlans: [{
id: 'plan-1',
versionId: 'version-1',
type: 'research',
title: 'Research login',
status: 'pending',
ownerId: 'member-1',
expectedStartAt: '2026-01-01',
expectedEndAt: '2026-01-05T18:00:00.000Z',
requirementCoverage: [{ requirementId: 'req-1', status: 'completed', updatedAt: '2026-01-02T00:00:00.000Z', updatedBy: 'member-1' }],
logs: [],
createdAt: '2026-01-01T00:00:00.000Z',
}],
devTasks: [{
id: 'dev-1',
versionId: 'version-1',
requirementId: 'req-1',
categoryId: 'cat-dev',
code: 'DEV-001',
title: 'Build login',
description: 'Implement login',
status: 'in_progress',
priority: 2,
assigneeId: 'member-2',
creatorId: 'member-1',
isBlocked: false,
expectedStartAt: '2026-01-02T09:00:00.000Z',
expectedEndAt: '2026-01-03T18:00:00.000Z',
startDate: '2026-01-02T09:00:00.000Z',
completedAt: null,
estimateHours: 8,
references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }],
aiDraft: false,
createdAt: '2026-01-02T08:00:00.000Z',
updatedAt: '2026-01-02T09:00:00.000Z',
}],
testCases: [{
id: 'tc-1',
versionId: 'version-1',
requirementId: 'req-1',
categoryId: 'cat-test',
code: 'TC-001',
title: 'Verify login',
description: 'Check password login',
status: 'pending',
roundNo: 1,
priority: 3,
assigneeId: 'member-3',
creatorId: 'member-1',
references: [],
aiDraft: false,
createdAt: '2026-01-03T08:00:00.000Z',
updatedAt: '2026-01-03T08:00:00.000Z',
}],
bugs: [{
id: 'bug-1',
versionId: 'version-1',
testCaseId: 'tc-1',
code: 'BUG-001',
title: 'Login error',
description: 'Wrong prompt',
status: 'open',
severity: 'major',
priority: 0,
assigneeId: 'member-2',
reporterId: 'member-3',
createdAt: '2026-01-04T08:00:00.000Z',
updatedAt: '2026-01-04T08:00:00.000Z',
}],
});
}
throw new Error(`Unexpected fetch ${url}`);
}) as typeof fetch;
try {
__resetApiAvailabilityForTests();
const result = await loadV22VersionDetailData('version-1');
assert.equal(calls[1].url, `${apiBase}/v2.2/versions/version-1/detail-data`);
assert.equal(result.scope.requirements[0].typeId, 'feature');
assert.deepEqual(result.scope.requirements[0].platforms, ['web', 'ios']);
assert.equal(result.scope.requirements[0].priority, 'P1');
assert.equal(result.scope.requirements[0].creator, 'member-1');
assert.equal(result.scope.devTasks[0].taskNo, 'DEV-001');
assert.equal(result.scope.devTasks[0].actualStartAt, '2026-01-02T09:00:00.000Z');
assert.equal(result.scope.devTasks[0].actualEndAt, undefined);
assert.equal(result.scope.devTasks[0].createdBy, 'member-1');
assert.equal(result.scope.testCases[0].caseNo, 'TC-001');
assert.equal(result.scope.testCases[0].createdBy, 'member-1');
assert.equal(result.scope.bugs[0].bugNo, 'BUG-001');
assert.equal(result.scope.bugs[0].reportedBy, 'member-3');
assert.equal(result.scope.plans[0].owner, 'member-1');
assert.equal(result.scope.plans[0].startTime, '2026-01-01T00:00:00.000Z');
assert.equal(result.scope.plansByType.research[0].id, 'plan-1');
assert.equal(result.scope.requirementIdSet.has('req-1'), true);
} finally {
globalThis.fetch = originalFetch;
}
});
test('V2.2 list APIs build scoped query strings without full-table reads', async () => {
const originalFetch = globalThis.fetch;
const apiBase = resolveApiBase();
const urls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
if (url === `${apiBase}/config/ai`) return jsonResponse({});
if (url.startsWith(`${apiBase}/v2.2/requirements?`)) return jsonResponse({ items: [], nextCursor: 'req-2' });
if (url.startsWith(`${apiBase}/v2.2/workspace?`)) return jsonResponse({ versionPlans: [], devTasks: [], testCases: [], bugs: [] });
if (url.startsWith(`${apiBase}/v2.2/xiaobao-warning?`)) return jsonResponse([]);
throw new Error(`Unexpected fetch ${url}`);
}) as typeof fetch;
try {
__resetApiAvailabilityForTests();
await loadV22RequirementsPage({
productId: 'product-1',
projectId: 'project-1',
status: 'adopted',
priority: 'P1',
type: 'feature',
q: '登录',
sort: 'created_at_asc',
cursor: 'req-1',
limit: 50,
});
await loadV22WorkspaceData('member-1');
await loadV22XiaobaoWarnings({ userId: 'member-1' });
const requirementUrl = new URL(urls[1]);
assert.equal(requirementUrl.pathname.endsWith('/v2.2/requirements'), true);
assert.equal(requirementUrl.searchParams.get('productId'), 'product-1');
assert.equal(requirementUrl.searchParams.get('projectId'), 'project-1');
assert.equal(requirementUrl.searchParams.get('status'), 'adopted');
assert.equal(requirementUrl.searchParams.get('priority'), 'P1');
assert.equal(requirementUrl.searchParams.get('type'), 'feature');
assert.equal(requirementUrl.searchParams.get('q'), '登录');
assert.equal(requirementUrl.searchParams.get('sort'), 'created_at_asc');
assert.equal(requirementUrl.searchParams.get('cursor'), 'req-1');
assert.equal(requirementUrl.searchParams.get('limit'), '50');
assert.equal(new URL(urls[2]).searchParams.get('userId'), 'member-1');
assert.equal(new URL(urls[3]).searchParams.get('userId'), 'member-1');
} finally {
globalThis.fetch = originalFetch;
}
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}

481
apps/web/lib/v22-api.ts Normal file
View File

@@ -0,0 +1,481 @@
import { api } from './api';
import type { Bug, BugSeverity, BugStatus } from './bug';
import type { DevTask, DevTaskStatus, Reference } from './dev-task';
import type { Priority } from './derive';
import type { Requirement, RequirementStatus, SourceType } from './requirement';
import type { TestCase, TestCaseStatus } from './test-case';
import type { VersionDataScope } from './version-data-scope';
import type {
PlanTask,
VersionPlan,
VersionPlanLog,
VersionPlanRequirementCoverage,
} from './version-plan';
type V22DateValue = string | Date | null | undefined;
interface V22VersionRow {
id: string;
productId: string;
projectId?: string | null;
name: string;
releaseDate?: V22DateValue;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22RequirementRow {
id: string;
productId: string;
projectId?: string | null;
versionId?: string | null;
code: string;
title: string;
description?: string | null;
status?: string | null;
priority?: number | string | null;
type?: string | null;
sourceType?: string | null;
sourceTarget?: string | null;
platform?: string | null;
creatorId?: string | null;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22VersionPlanRow {
id: string;
versionId: string;
type: string;
title: string;
status?: string | null;
ownerId?: string | null;
expectedStartAt?: V22DateValue;
expectedEndAt?: V22DateValue;
actualStartAt?: V22DateValue;
completedAt?: V22DateValue;
resultUrl?: string | null;
requirementCoverage?: unknown;
logs?: unknown;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22DevTaskRow {
id: string;
versionId: string;
requirementId?: string | null;
categoryId?: string | null;
code: string;
title: string;
description?: string | null;
status?: string | null;
priority?: number | string | null;
assigneeId?: string | null;
creatorId?: string | null;
isBlocked?: boolean | null;
blockReason?: string | null;
expectedStartAt?: V22DateValue;
expectedEndAt?: V22DateValue;
startDate?: V22DateValue;
completedAt?: V22DateValue;
estimateHours?: number | null;
aiEstimateHours?: number | null;
references?: unknown;
aiDraft?: boolean | null;
aiDraftAt?: V22DateValue;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22TestCaseRow {
id: string;
versionId: string;
requirementId?: string | null;
categoryId?: string | null;
code: string;
title: string;
description?: string | null;
status?: string | null;
roundNo?: number | null;
priority?: number | string | null;
assigneeId?: string | null;
creatorId?: string | null;
plannedTestAt?: V22DateValue;
plannedEndAt?: V22DateValue;
startedAt?: V22DateValue;
completedAt?: V22DateValue;
estimateHours?: number | null;
aiEstimateHours?: number | null;
references?: unknown;
aiDraft?: boolean | null;
aiDraftAt?: V22DateValue;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22BugRow {
id: string;
versionId: string;
testCaseId?: string | null;
code: string;
title: string;
description?: string | null;
status?: string | null;
severity?: string | null;
priority?: number | string | null;
assigneeId?: string | null;
reporterId?: string | null;
plannedFixAt?: V22DateValue;
resolvedAt?: V22DateValue;
closedAt?: V22DateValue;
resolution?: string | null;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22VersionDetailResponse {
version: V22VersionRow;
requirements: V22RequirementRow[];
versionPlans: V22VersionPlanRow[];
devTasks: V22DevTaskRow[];
testCases: V22TestCaseRow[];
bugs: V22BugRow[];
}
export interface V22VersionDetailData {
version: V22VersionRow;
scope: VersionDataScope;
}
export interface V22RequirementsQuery {
productId: string;
projectId?: string;
versionId?: string;
status?: string;
priority?: string;
type?: string;
q?: string;
sort?: string;
cursor?: string;
limit?: number;
}
export interface V22RequirementsPage {
items: Requirement[];
nextCursor?: string;
}
export interface V22WorkspaceData {
versionPlans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
}
export interface V22XiaobaoWarningQuery {
userId?: string;
manager?: boolean;
}
export interface V22XiaobaoWarningSummary {
versionId: string;
riskLevel: string;
riskScore: number;
confidence: number;
forecastReleaseDate?: string;
riskSignature: string;
summary: unknown;
dirty?: boolean;
recomputedAt?: string;
updatedAt?: string;
}
export async function loadV22VersionDetailData(versionId: string): Promise<V22VersionDetailData> {
const response = await api.get<V22VersionDetailResponse>(
`/v2.2/versions/${encodeURIComponent(versionId)}/detail-data`,
);
return {
version: response.version,
scope: buildScope(response),
};
}
export async function loadV22RequirementsPage(query: V22RequirementsQuery): Promise<V22RequirementsPage> {
const response = await api.get<{ items: V22RequirementRow[]; nextCursor?: string }>(
`/v2.2/requirements?${queryString(query)}`,
);
return {
items: response.items.map(mapRequirement),
nextCursor: response.nextCursor,
};
}
export async function loadV22WorkspaceData(userId: string): Promise<V22WorkspaceData> {
const response = await api.get<{
versionPlans: V22VersionPlanRow[];
devTasks: V22DevTaskRow[];
testCases: V22TestCaseRow[];
bugs: V22BugRow[];
}>(`/v2.2/workspace?${queryString({ userId })}`);
return {
versionPlans: response.versionPlans.map(mapVersionPlan),
devTasks: response.devTasks.map(mapDevTask),
testCases: response.testCases.map(mapTestCase),
bugs: response.bugs.map(mapBug),
};
}
export async function loadV22XiaobaoWarnings(
query: V22XiaobaoWarningQuery,
): Promise<V22XiaobaoWarningSummary[]> {
const response = await api.get<Array<Omit<V22XiaobaoWarningSummary, 'forecastReleaseDate' | 'recomputedAt' | 'updatedAt'> & {
forecastReleaseDate?: V22DateValue;
recomputedAt?: V22DateValue;
updatedAt?: V22DateValue;
}>>(`/v2.2/xiaobao-warning?${queryString({
userId: query.userId,
manager: query.manager ? 'true' : undefined,
})}`);
return response.map((row) => ({
...row,
forecastReleaseDate: optionalIso(row.forecastReleaseDate),
recomputedAt: optionalIso(row.recomputedAt),
updatedAt: optionalIso(row.updatedAt),
}));
}
function buildScope(response: V22VersionDetailResponse): VersionDataScope {
const requirements = response.requirements.map(mapRequirement);
const requirementIds = requirements.map((requirement) => requirement.id);
const requirementIdSet = new Set(requirementIds);
const plans = response.versionPlans.map(mapVersionPlan);
const plansByType: VersionDataScope['plansByType'] = { research: [], product: [], ui: [] };
for (const plan of plans) {
plansByType[plan.type].push(plan);
}
return {
requirements,
requirementIds,
requirementIdSet,
plans,
plansByType,
devTasks: response.devTasks.map(mapDevTask),
testCases: response.testCases.map(mapTestCase),
bugs: response.bugs.map(mapBug),
overtimeRecords: [],
};
}
function mapRequirement(row: V22RequirementRow): Requirement {
return {
id: row.id,
code: row.code,
title: row.title,
description: row.description ?? '',
productId: row.productId,
projectId: row.projectId ?? '',
versionId: row.versionId ?? undefined,
sourceType: toSourceType(row.sourceType),
sourceTarget: row.sourceTarget ?? '',
platforms: splitCsv(row.platform),
typeId: row.type ?? '',
status: toRequirementStatus(row.status),
priority: toPriority(row.priority),
effort: 'M',
creator: row.creatorId ?? '',
createdAt: requiredIso(row.createdAt),
};
}
function mapVersionPlan(row: V22VersionPlanRow): VersionPlan {
const coverage = asArray<VersionPlanRequirementCoverage>(row.requirementCoverage);
return {
id: row.id,
versionId: row.versionId,
type: toPlanType(row.type),
title: row.title,
owner: row.ownerId ?? '',
startTime: requiredIso(row.expectedStartAt),
endTime: requiredIso(row.expectedEndAt),
status: toPlanStatus(row.status),
tasks: [],
completedRequirementIds: coverage
.filter((item) => item?.status === 'completed')
.map((item) => item.requirementId)
.filter(Boolean),
linkedRequirementIds: coverage.map((item) => item?.requirementId).filter(Boolean),
requirementCoverage: coverage,
logs: asArray<VersionPlanLog>(row.logs),
resultUrl: row.resultUrl ?? undefined,
actualStartAt: optionalIso(row.actualStartAt),
createdAt: requiredIso(row.createdAt),
completedAt: optionalIso(row.completedAt),
addedBy: row.ownerId ?? '',
};
}
function mapDevTask(row: V22DevTaskRow): DevTask {
return {
id: row.id,
taskNo: row.code,
versionId: row.versionId,
requirementId: row.requirementId ?? '',
title: row.title,
description: row.description ?? '',
categoryId: row.categoryId ?? '',
assigneeId: row.assigneeId ?? '',
priority: toPriority(row.priority),
expectedStartAt: requiredIso(row.expectedStartAt),
expectedEndAt: requiredIso(row.expectedEndAt),
estimateHours: row.estimateHours ?? undefined,
aiEstimateHours: row.aiEstimateHours ?? undefined,
actualStartAt: optionalIso(row.startDate),
actualEndAt: optionalIso(row.completedAt),
status: toDevTaskStatus(row.status),
isBlocked: row.isBlocked ?? false,
blockReason: row.blockReason ?? undefined,
references: asArray<Reference>(row.references),
aiDraft: row.aiDraft ?? false,
aiDraftAt: optionalIso(row.aiDraftAt),
createdBy: row.creatorId ?? '',
createdAt: requiredIso(row.createdAt),
updatedAt: requiredIso(row.updatedAt),
};
}
function mapTestCase(row: V22TestCaseRow): TestCase {
return {
id: row.id,
caseNo: row.code,
versionId: row.versionId,
requirementId: row.requirementId ?? undefined,
roundNo: row.roundNo ?? 1,
title: row.title,
description: row.description ?? '',
categoryId: row.categoryId ?? '',
priority: toPriority(row.priority),
assigneeId: row.assigneeId ?? undefined,
status: toTestCaseStatus(row.status),
estimateHours: row.estimateHours ?? undefined,
aiEstimateHours: row.aiEstimateHours ?? undefined,
plannedTestAt: optionalIso(row.plannedTestAt),
plannedEndAt: optionalIso(row.plannedEndAt),
startedAt: optionalIso(row.startedAt),
completedAt: optionalIso(row.completedAt),
references: asArray<Reference>(row.references),
aiDraft: row.aiDraft ?? false,
aiDraftAt: optionalIso(row.aiDraftAt),
createdBy: row.creatorId ?? '',
createdAt: requiredIso(row.createdAt),
updatedAt: requiredIso(row.updatedAt),
};
}
function mapBug(row: V22BugRow): Bug {
return {
id: row.id,
bugNo: row.code,
versionId: row.versionId,
testCaseId: row.testCaseId ?? '',
title: row.title,
description: row.description ?? '',
severity: toBugSeverity(row.severity),
priority: toPriority(row.priority),
reportedBy: row.reporterId ?? '',
assigneeId: row.assigneeId ?? '',
status: toBugStatus(row.status),
resolvedAt: optionalIso(row.resolvedAt),
closedAt: optionalIso(row.closedAt),
resolution: row.resolution ?? undefined,
plannedFixAt: optionalIso(row.plannedFixAt),
createdAt: requiredIso(row.createdAt),
updatedAt: requiredIso(row.updatedAt),
};
}
function queryString<T extends object>(values: T): string {
const params = new URLSearchParams();
Object.entries(values).forEach(([key, value]) => {
if (value === undefined || value === '') return;
params.set(key, String(value));
});
return params.toString();
}
function requiredIso(value: V22DateValue): string {
return optionalIso(value) ?? '';
}
function optionalIso(value: V22DateValue): string | undefined {
if (!value) return undefined;
if (value instanceof Date) return value.toISOString();
const trimmed = value.trim();
if (!trimmed) return undefined;
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return `${trimmed}T00:00:00.000Z`;
const parsed = new Date(trimmed);
if (Number.isFinite(parsed.getTime())) return parsed.toISOString();
return trimmed;
}
function splitCsv(value?: string | null): string[] {
if (!value) return [];
return value.split(',').map((item) => item.trim()).filter(Boolean);
}
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : [];
}
function toPriority(value: number | string | null | undefined): Priority {
if (typeof value === 'string') {
const normalized = value.trim().toUpperCase();
if (/^P[0-4]$/.test(normalized)) return normalized as Priority;
const parsed = Number(normalized);
if (Number.isFinite(parsed)) return toPriority(parsed);
}
const rank = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 2;
return `P${Math.max(0, Math.min(4, rank))}` as Priority;
}
function toSourceType(value?: string | null): SourceType {
const allowed: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
return allowed.includes(value as SourceType) ? value as SourceType : 'internal';
}
function toRequirementStatus(value?: string | null): RequirementStatus {
const allowed: RequirementStatus[] = ['pending_review', 'adopted', 'rejected', 'planned', 'developing', 'testing', 'released', 'closed'];
return allowed.includes(value as RequirementStatus) ? value as RequirementStatus : 'pending_review';
}
function toPlanType(value: string): VersionPlan['type'] {
return value === 'research' || value === 'ui' ? value : 'product';
}
function toPlanStatus(value?: string | null): VersionPlan['status'] {
if (value === 'in_progress' || value === 'completed') return value;
return 'pending';
}
function toDevTaskStatus(value?: string | null): DevTaskStatus {
if (value === 'in_progress' || value === 'testing' || value === 'submitted') return value;
return 'todo';
}
function toTestCaseStatus(value?: string | null): TestCaseStatus {
if (value === 'running' || value === 'passed' || value === 'failed' || value === 'blocked') return value;
return 'pending';
}
function toBugStatus(value?: string | null): BugStatus {
if (value === 'fixing' || value === 'fixed' || value === 'verifying' || value === 'closed' || value === 'rejected') {
return value;
}
return 'open';
}
function toBugSeverity(value?: string | null): BugSeverity {
if (value === 'critical' || value === 'major' || value === 'trivial') return value;
return 'minor';
}

View File

@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildVersionDataScope, buildVersionDataScopeMap } from './version-data-scope';
import { buildVersionDataScope, buildVersionDataScopeMap, selectVersionDataScope } from './version-data-scope';
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { OvertimeRecord } from './overtime';
@@ -169,3 +169,53 @@ test('buildVersionDataScopeMap indexes many versions in one pass', () => {
assert.deepEqual(scopeMap['version-empty'].requirements, []);
assert.deepEqual(Object.keys(scopeMap).sort(), ['version-1', 'version-2', 'version-empty']);
});
test('selectVersionDataScope prefers non-empty V2.2 scope but keeps AppData overtime records', () => {
const appDataScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-app', 'version-1')],
plans: [plan('plan-app', 'version-1', 'research')],
devTasks: [devTask('dev-app', { versionId: 'version-1' })],
testCases: [testCase('tc-app', 'version-1')],
bugs: [bug('bug-app', 'version-1')],
overtimeRecords: [overtime('ot-app', 'version-1')],
});
const v22Scope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-v22', 'version-1')],
plans: [plan('plan-v22', 'version-1', 'product')],
devTasks: [devTask('dev-v22', { versionId: 'version-1' })],
testCases: [],
bugs: [],
overtimeRecords: [],
});
const selected = selectVersionDataScope({ appDataScope, v22Scope });
assert.equal(selected?.requirements[0].id, 'req-v22');
assert.equal(selected?.plans[0].id, 'plan-v22');
assert.deepEqual(selected?.overtimeRecords.map((item) => item.id), ['ot-app']);
});
test('selectVersionDataScope falls back to AppData when V2.2 scope is empty', () => {
const appDataScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-app', 'version-1')],
plans: [],
devTasks: [],
testCases: [],
bugs: [],
});
const emptyV22Scope = buildVersionDataScope({
versionId: 'version-1',
requirements: [],
plans: [],
devTasks: [],
testCases: [],
bugs: [],
});
const selected = selectVersionDataScope({ appDataScope, v22Scope: emptyV22Scope });
assert.equal(selected?.requirements[0].id, 'req-app');
});

View File

@@ -77,6 +77,31 @@ export function buildVersionDataScope(input: VersionDataScopeInput): VersionData
};
}
export function hasVersionDataScopeRows(scope: VersionDataScope | null | undefined): scope is VersionDataScope {
if (!scope) return false;
return (
scope.requirements.length > 0 ||
scope.plans.length > 0 ||
scope.devTasks.length > 0 ||
scope.testCases.length > 0 ||
scope.bugs.length > 0
);
}
export function selectVersionDataScope(input: {
appDataScope: VersionDataScope | null;
v22Scope?: VersionDataScope | null;
}): VersionDataScope | null {
const { appDataScope, v22Scope } = input;
if (hasVersionDataScopeRows(v22Scope)) {
return {
...v22Scope,
overtimeRecords: appDataScope?.overtimeRecords ?? v22Scope.overtimeRecords,
};
}
return appDataScope;
}
export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record<string, VersionDataScope> {
const scopes: Record<string, VersionDataScope> = {};
const targetVersionIds = new Set(input.versionIds);

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { selectWorkspaceCollections } from './workspace-v22-source';
const appData = {
plans: [{ id: 'app-plan' }] as any[],
devTasks: [{ id: 'app-dev' }] as any[],
testCases: [{ id: 'app-test' }] as any[],
bugs: [{ id: 'app-bug' }] as any[],
};
test('selectWorkspaceCollections prefers successfully loaded V2.2 data even when it is empty', () => {
const selected = selectWorkspaceCollections({
v22Loaded: true,
v22Failed: false,
v22Data: { versionPlans: [], devTasks: [], testCases: [], bugs: [] },
appData,
});
assert.deepEqual(selected, { versionPlans: [], devTasks: [], testCases: [], bugs: [] });
});
test('selectWorkspaceCollections falls back to AppData only when V2.2 is unavailable', () => {
const selected = selectWorkspaceCollections({
v22Loaded: false,
v22Failed: true,
v22Data: null,
appData,
});
assert.deepEqual(selected, {
versionPlans: appData.plans,
devTasks: appData.devTasks,
testCases: appData.testCases,
bugs: appData.bugs,
});
});

View File

@@ -0,0 +1,47 @@
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { V22WorkspaceData } from './v22-api';
import type { VersionPlan } from './version-plan';
export interface WorkspaceCollections {
versionPlans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
}
export interface SelectWorkspaceCollectionsInput {
v22Loaded: boolean;
v22Failed: boolean;
v22Data: V22WorkspaceData | null;
appData: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
};
}
export function selectWorkspaceCollections({
v22Loaded,
v22Failed,
v22Data,
appData,
}: SelectWorkspaceCollectionsInput): WorkspaceCollections {
if (v22Loaded && !v22Failed && v22Data) {
return {
versionPlans: v22Data.versionPlans,
devTasks: v22Data.devTasks,
testCases: v22Data.testCases,
bugs: v22Data.bugs,
};
}
return {
versionPlans: appData.plans,
devTasks: appData.devTasks,
testCases: appData.testCases,
bugs: appData.bugs,
};
}

View File

@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { VersionWithContext } from './derive';
import type { V22XiaobaoWarningSummary } from './v22-api';
import {
buildXiaobaoRisksFromV22Summaries,
filterV22XiaobaoSummariesForVisibleVersions,
shouldLoadXiaobaoAppDataFallback,
} from './xiaobao-v22-summary';
test('buildXiaobaoRisksFromV22Summaries preserves precomputed risk details with version context', () => {
const risks = buildXiaobaoRisksFromV22Summaries({
summaries: [{
versionId: 'version-1',
riskLevel: 'likely_delayed',
riskScore: 86,
confidence: 72,
forecastReleaseDate: '2026-07-09T00:00:00.000Z',
riskSignature: 'signature-1',
recomputedAt: '2026-07-03T09:00:00.000Z',
updatedAt: '2026-07-03T09:05:00.000Z',
summary: {
delayDays: 2,
remainingWorkHours: 30,
reasons: [{
key: 'forecast_delay',
title: 'Forecast delay',
detail: 'Forecast release date moved after the expected release date.',
severity: 'danger',
count: 1,
}],
silentRisks: [{
key: 'no_activity',
title: 'No activity',
detail: 'No work activity for four days.',
itemId: 'dev-1',
itemType: 'dev_task',
}],
signals: {
unfinishedCount: 5,
openBugCount: 2,
criticalBugCount: 1,
failedTestCount: 1,
blockedCount: 0,
silentRiskCount: 1,
daysToExpectedRelease: 1,
},
dailyEvidence: {
todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core task.', occurredAt: '2026-07-03T08:00:00.000Z' }],
todayProgress: [],
todayCreations: [],
todayRisks: [],
progressNotes: [],
needsProgressItems: [],
recentActivityCount: 3,
totalActivityCount: 1,
todayActualHours: 2,
lastActivityAt: '2026-07-03T08:00:00.000Z',
},
trend: {
direction: 'up',
delta: 16,
summary: 'Risk rose by 16 points.',
pattern: 'score_delta',
},
currentSnapshot: {
date: '2026-07-03',
openBugCount: 2,
criticalBugCount: 1,
failedTestCount: 1,
blockedCount: 0,
silentRiskCount: 1,
createdAt: '2026-07-03T09:00:00.000Z',
},
},
}],
versions: [version()],
snapshots: [],
now: new Date('2026-07-03T10:00:00.000Z'),
});
assert.equal(risks.length, 1);
assert.equal(risks[0].versionName, 'Payments V1.0');
assert.equal(risks[0].productName, 'Payments');
assert.equal(risks[0].projectName, 'Wallet');
assert.equal(risks[0].expectedReleaseDate, '2026-07-07');
assert.equal(risks[0].riskLevel, 'likely_delayed');
assert.equal(risks[0].riskScore, 86);
assert.equal(risks[0].confidence, 72);
assert.equal(risks[0].confidenceLevel, 'medium');
assert.equal(risks[0].forecastReleaseDate, '2026-07-09T00:00:00.000Z');
assert.equal(risks[0].delayDays, 2);
assert.equal(risks[0].remainingWorkHours, 30);
assert.equal(risks[0].reasons[0].key, 'forecast_delay');
assert.equal(risks[0].silentRisks[0].itemType, 'dev_task');
assert.equal(risks[0].signals.openBugCount, 2);
assert.equal(risks[0].dailyEvidence?.todayDeliveries[0].summary, 'Submitted core task.');
assert.equal(risks[0].currentSnapshot.date, '2026-07-03');
assert.equal(risks[0].currentSnapshot.riskScore, 86);
assert.equal(risks[0].currentSnapshot.riskLevel, 'likely_delayed');
assert.equal(risks[0].trend.summary, 'Risk rose by 16 points.');
});
test('buildXiaobaoRisksFromV22Summaries builds compatible defaults from sparse summaries', () => {
const risks = buildXiaobaoRisksFromV22Summaries({
summaries: [{
versionId: 'version-1',
riskLevel: 'at_risk',
riskScore: 66,
confidence: 48,
forecastReleaseDate: '2026-07-09T00:00:00.000Z',
riskSignature: 'signature-1',
updatedAt: '2026-07-03T09:05:00.000Z',
summary: { reasons: 'not-an-array' },
}],
versions: [version({ expectedReleaseDate: '2026-07-07' })],
snapshots: [{
versionId: 'version-1',
date: '2026-07-01',
riskScore: 40,
riskLevel: 'attention',
openBugCount: 1,
criticalBugCount: 0,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
confidence: 80,
createdAt: '2026-07-01T09:00:00.000Z',
}],
now: new Date('2026-07-03T10:00:00.000Z'),
});
assert.equal(risks[0].delayDays, 2);
assert.equal(risks[0].remainingWorkHours, 0);
assert.equal(risks[0].confidenceLevel, 'low');
assert.equal(risks[0].reasons.length, 1);
assert.equal(risks[0].reasons[0].key, 'precomputed_risk');
assert.equal(risks[0].signals.unfinishedCount, 0);
assert.equal(risks[0].currentSnapshot.openBugCount, 0);
assert.equal(risks[0].currentSnapshot.createdAt, '2026-07-03T09:05:00.000Z');
assert.equal(risks[0].trend.direction, 'up');
});
test('shouldLoadXiaobaoAppDataFallback waits for V2.2 summary before loading heavy AppData stores', () => {
assert.equal(shouldLoadXiaobaoAppDataFallback('idle'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('loading'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('ready'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('empty'), true);
assert.equal(shouldLoadXiaobaoAppDataFallback('failed'), true);
});
test('filterV22XiaobaoSummariesForVisibleVersions keeps only visible version summaries', () => {
const summaries = [
summary({ versionId: 'visible-1', riskScore: 80 }),
summary({ versionId: 'hidden-1', riskScore: 95 }),
summary({ versionId: 'visible-2', riskScore: 70 }),
];
const result = filterV22XiaobaoSummariesForVisibleVersions(summaries, [
version({ id: 'visible-1' }),
version({ id: 'visible-2' }),
]);
assert.deepEqual(result.map((item) => item.versionId), ['visible-1', 'visible-2']);
});
function summary(patch: Partial<V22XiaobaoWarningSummary> = {}): V22XiaobaoWarningSummary {
return {
versionId: 'version-1',
riskLevel: 'at_risk',
riskScore: 60,
confidence: 80,
riskSignature: 'signature',
summary: {},
updatedAt: '2026-07-03T09:00:00.000Z',
...patch,
};
}
function version(patch: Partial<VersionWithContext> = {}): VersionWithContext {
return {
id: 'version-1',
name: 'Payments V1.0',
status: 'developing',
releaseDate: null,
createdAt: '2026-07-01T00:00:00.000Z',
productId: 'product-1',
productName: 'Payments',
projectId: 'project-1',
projectName: 'Wallet',
expectedReleaseDate: '2026-07-07',
members: [{ name: 'Alice', role: 'frontend' }],
...patch,
} as VersionWithContext;
}

View File

@@ -0,0 +1,345 @@
import type { VersionWithContext } from './derive';
import type { V22XiaobaoWarningSummary } from './v22-api';
import type {
RiskReason,
SilentRisk,
XiaobaoConfidenceLevel,
XiaobaoRiskLevel,
XiaobaoRiskSignals,
XiaobaoVersionRisk,
} from './xiaobao-risk';
import type { EvidenceItem, VersionDailyEvidence } from './xiaobao-risk-evidence';
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
interface BuildXiaobaoRisksFromV22SummariesInput {
summaries: V22XiaobaoWarningSummary[];
versions: VersionWithContext[];
snapshots?: XiaobaoRiskSnapshot[];
now?: Date;
}
export type V22XiaobaoSummaryLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'failed';
const RISK_LEVELS = new Set<XiaobaoRiskLevel>(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked']);
const REASON_SEVERITIES = new Set<RiskReason['severity']>(['info', 'warning', 'danger']);
const SILENT_RISK_ITEM_TYPES = new Set<NonNullable<SilentRisk['itemType']>>(['dev_task', 'test_case', 'bug', 'version']);
export function shouldLoadXiaobaoAppDataFallback(state: V22XiaobaoSummaryLoadState): boolean {
return state === 'empty' || state === 'failed';
}
export function filterV22XiaobaoSummariesForVisibleVersions(
summaries: V22XiaobaoWarningSummary[],
versions: VersionWithContext[],
): V22XiaobaoWarningSummary[] {
const visibleVersionIds = new Set(versions.map((version) => version.id));
if (visibleVersionIds.size === 0) return [];
return summaries.filter((summary) => visibleVersionIds.has(summary.versionId));
}
export function buildXiaobaoRisksFromV22Summaries(
input: BuildXiaobaoRisksFromV22SummariesInput,
): XiaobaoVersionRisk[] {
const now = input.now ?? new Date();
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
return input.summaries
.map((summary) => mapSummaryToRisk(summary, versionMap.get(summary.versionId), input.snapshots ?? [], now))
.sort((a, b) => b.riskScore - a.riskScore);
}
function mapSummaryToRisk(
row: V22XiaobaoWarningSummary,
version: VersionWithContext | undefined,
snapshots: XiaobaoRiskSnapshot[],
now: Date,
): XiaobaoVersionRisk {
const payload = getRiskPayload(row.summary);
const riskLevel = toRiskLevel(row.riskLevel) ?? toRiskLevel(readString(payload.riskLevel)) ?? 'attention';
const riskScore = clampScore(readNumber(payload.riskScore) ?? row.riskScore);
const confidence = clampScore(readNumber(payload.confidence) ?? row.confidence);
const forecastReleaseDate = readString(payload.forecastReleaseDate) ?? row.forecastReleaseDate;
const expectedReleaseDate =
readString(payload.expectedReleaseDate)
?? version?.expectedReleaseDate
?? version?.releaseDate
?? null;
const delayDays = nonNegativeNumber(readNumber(payload.delayDays)) ?? calcDelayDays(expectedReleaseDate, forecastReleaseDate);
const remainingWorkHours = nonNegativeNumber(readNumber(payload.remainingWorkHours)) ?? 0;
const dailyEvidence = readDailyEvidence(payload.dailyEvidence);
const silentRisks = readSilentRisks(payload.silentRisks);
const signals = readSignals(payload.signals, dailyEvidence, silentRisks, expectedReleaseDate, now);
const reasons = readReasons(payload.reasons);
const sourceTime = row.recomputedAt ?? row.updatedAt ?? now.toISOString();
const currentSnapshot = buildCurrentSnapshot({
payload,
row,
riskLevel,
riskScore,
confidence,
forecastReleaseDate,
signals,
sourceTime,
now,
});
const trend = readTrend(payload.trend)
?? summarizeRiskTrendWithCurrent(
snapshots.filter((snapshot) => snapshot.versionId === row.versionId),
currentSnapshot,
);
return {
versionId: row.versionId,
versionName: readString(payload.versionName) ?? version?.name ?? row.versionId,
productId: readString(payload.productId) ?? version?.productId,
productName: readString(payload.productName) ?? version?.productName,
projectId: readString(payload.projectId) ?? version?.projectId,
projectName: readString(payload.projectName) ?? version?.projectName,
riskScore,
riskLevel,
expectedReleaseDate,
confidence,
confidenceLevel: getConfidenceLevel(confidence),
forecastReleaseDate,
delayDays,
remainingWorkHours,
reasons: reasons.length > 0 ? reasons : buildDefaultReasons(riskLevel, riskScore),
silentRisks,
dailyEvidence,
signals,
currentSnapshot,
trend,
};
}
function getRiskPayload(summary: unknown): Record<string, unknown> {
const root = readRecord(summary);
const nested = readRecord(root.risk);
return Object.keys(nested).length > 0 ? nested : root;
}
function buildCurrentSnapshot(input: {
payload: Record<string, unknown>;
row: V22XiaobaoWarningSummary;
riskLevel: XiaobaoRiskLevel;
riskScore: number;
confidence: number;
forecastReleaseDate?: string;
signals: XiaobaoRiskSignals;
sourceTime: string;
now: Date;
}): XiaobaoRiskSnapshot {
const snapshot = readRecord(input.payload.currentSnapshot);
const createdAt = readString(snapshot.createdAt) ?? input.sourceTime ?? input.now.toISOString();
return {
versionId: input.row.versionId,
date: readString(snapshot.date) ?? createdAt.slice(0, 10),
riskScore: clampScore(readNumber(snapshot.riskScore) ?? input.riskScore),
riskLevel: toRiskLevel(readString(snapshot.riskLevel)) ?? input.riskLevel,
forecastReleaseDate: readString(snapshot.forecastReleaseDate) ?? input.forecastReleaseDate,
openBugCount: nonNegativeInteger(readNumber(snapshot.openBugCount)) ?? input.signals.openBugCount,
criticalBugCount: nonNegativeInteger(readNumber(snapshot.criticalBugCount)) ?? input.signals.criticalBugCount,
failedTestCount: nonNegativeInteger(readNumber(snapshot.failedTestCount)) ?? input.signals.failedTestCount,
blockedCount: nonNegativeInteger(readNumber(snapshot.blockedCount)) ?? input.signals.blockedCount,
silentRiskCount: nonNegativeInteger(readNumber(snapshot.silentRiskCount)) ?? input.signals.silentRiskCount,
confidence: clampScore(readNumber(snapshot.confidence) ?? input.confidence),
createdAt,
};
}
function readSignals(
value: unknown,
dailyEvidence: VersionDailyEvidence | undefined,
silentRisks: SilentRisk[],
expectedReleaseDate: string | null,
now: Date,
): XiaobaoRiskSignals {
const row = readRecord(value);
return {
unfinishedCount: nonNegativeInteger(readNumber(row.unfinishedCount)) ?? 0,
openBugCount: nonNegativeInteger(readNumber(row.openBugCount)) ?? 0,
criticalBugCount: nonNegativeInteger(readNumber(row.criticalBugCount)) ?? 0,
failedTestCount: nonNegativeInteger(readNumber(row.failedTestCount)) ?? 0,
blockedCount: nonNegativeInteger(readNumber(row.blockedCount)) ?? 0,
silentRiskCount:
nonNegativeInteger(readNumber(row.silentRiskCount))
?? dailyEvidence?.silentRisks?.length
?? silentRisks.length,
daysToExpectedRelease: readNumber(row.daysToExpectedRelease) ?? calcDaysToExpectedRelease(expectedReleaseDate, now),
};
}
function readReasons(value: unknown): RiskReason[] {
if (!Array.isArray(value)) return [];
return value.map((item) => {
const row = readRecord(item);
const key = readString(row.key);
const title = readString(row.title);
const detail = readString(row.detail);
if (!key || !title || !detail) return undefined;
return {
key,
title,
detail,
severity: toReasonSeverity(readString(row.severity)),
count: nonNegativeInteger(readNumber(row.count)),
};
}).filter(isDefined);
}
function readSilentRisks(value: unknown): SilentRisk[] {
if (!Array.isArray(value)) return [];
return value.map((item) => {
const row = readRecord(item);
const key = readString(row.key);
const title = readString(row.title);
const detail = readString(row.detail);
if (!key || !title || !detail) return undefined;
return {
key,
title,
detail,
itemId: readString(row.itemId),
itemType: toSilentRiskItemType(readString(row.itemType)),
};
}).filter(isDefined);
}
function readDailyEvidence(value: unknown): VersionDailyEvidence | undefined {
const row = readRecord(value);
if (Object.keys(row).length === 0) return undefined;
return {
todayDeliveries: readEvidenceItems(row.todayDeliveries),
todayProgress: readEvidenceItems(row.todayProgress),
todayCreations: readEvidenceItems(row.todayCreations),
todayRisks: readEvidenceItems(row.todayRisks),
progressNotes: readEvidenceItems(row.progressNotes),
needsProgressItems: readEvidenceItems(row.needsProgressItems),
recentActivityCount: nonNegativeInteger(readNumber(row.recentActivityCount)) ?? 0,
totalActivityCount: nonNegativeInteger(readNumber(row.totalActivityCount)) ?? 0,
todayActualHours: nonNegativeNumber(readNumber(row.todayActualHours)) ?? 0,
lastActivityAt: readString(row.lastActivityAt),
silentRisks: readSilentRisks(row.silentRisks),
};
}
function readEvidenceItems(value: unknown): EvidenceItem[] {
if (!Array.isArray(value)) return [];
return value.map((item, index) => {
const row = readRecord(item);
const summary = readString(row.summary);
if (!summary) return undefined;
return {
id: readString(row.id) ?? `v22-evidence-${index}`,
title: readString(row.title) ?? summary,
summary,
occurredAt: readString(row.occurredAt) ?? '',
actorId: readString(row.actorId),
};
}).filter(isDefined);
}
function readTrend(value: unknown): XiaobaoVersionRisk['trend'] | undefined {
const row = readRecord(value);
const direction = readString(row.direction);
const summary = readString(row.summary);
if (!direction || !summary) return undefined;
if (direction !== 'up' && direction !== 'down' && direction !== 'flat' && direction !== 'unknown') return undefined;
const pattern = readString(row.pattern);
return {
direction,
delta: readNumber(row.delta) ?? 0,
summary,
pattern: pattern === 'continuous_rising'
|| pattern === 'continuous_falling'
|| pattern === 'score_delta'
|| pattern === 'stable'
|| pattern === 'unknown'
? pattern
: undefined,
};
}
function buildDefaultReasons(riskLevel: XiaobaoRiskLevel, riskScore: number): RiskReason[] {
if (riskLevel === 'on_track') return [];
return [{
key: 'precomputed_risk',
title: 'Precomputed risk',
detail: `V2.2 precomputed summary reports risk score ${riskScore}.`,
severity: riskScore >= 75 ? 'danger' : 'warning',
}];
}
function toRiskLevel(value: string | undefined): XiaobaoRiskLevel | undefined {
return value && RISK_LEVELS.has(value as XiaobaoRiskLevel) ? value as XiaobaoRiskLevel : undefined;
}
function toReasonSeverity(value: string | undefined): RiskReason['severity'] {
return value && REASON_SEVERITIES.has(value as RiskReason['severity'])
? value as RiskReason['severity']
: 'warning';
}
function toSilentRiskItemType(value: string | undefined): SilentRisk['itemType'] | undefined {
return value && SILENT_RISK_ITEM_TYPES.has(value as NonNullable<SilentRisk['itemType']>)
? value as NonNullable<SilentRisk['itemType']>
: undefined;
}
function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel {
if (confidence >= 75) return 'high';
if (confidence >= 50) return 'medium';
return 'low';
}
function calcDelayDays(expectedReleaseDate: string | null, forecastReleaseDate: string | undefined): number {
const expected = parseDate(expectedReleaseDate);
const forecast = parseDate(forecastReleaseDate);
if (!expected || !forecast || forecast.getTime() <= expected.getTime()) return 0;
return Math.round(((forecast.getTime() - expected.getTime()) / 86_400_000) * 10) / 10;
}
function calcDaysToExpectedRelease(expectedReleaseDate: string | null, now: Date): number | undefined {
const expected = parseDate(expectedReleaseDate);
if (!expected) return undefined;
return Math.round(((expected.getTime() - now.getTime()) / 86_400_000) * 10) / 10;
}
function parseDate(value: string | null | undefined): Date | undefined {
if (!value) return undefined;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : undefined;
}
function readRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function nonNegativeNumber(value: number | undefined): number | undefined {
return value !== undefined && value >= 0 ? value : undefined;
}
function nonNegativeInteger(value: number | undefined): number | undefined {
return value !== undefined && value >= 0 ? Math.round(value) : undefined;
}
function clampScore(value: number): number {
return Math.max(0, Math.min(100, Math.round(value)));
}
function isDefined<T>(value: T | undefined): value is T {
return value !== undefined;
}