Files
ftb-project-management/apps/web/lib/xiaobao-risk-trend.ts
Script Generator 3d3d56697a feat(版本): 完善研发计划与预警已读
关键改动:

- 增加版本表单、发布校验和调研方向进度规则

- 扩展小宝预警已读状态、风险签名和今日证据

- 补充组长权限、加班查看范围、活动记录与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 16:48:58 +08:00

157 lines
5.3 KiB
TypeScript

import type { XiaobaoRiskLevel } from './xiaobao-risk';
export interface XiaobaoRiskSnapshot {
versionId: string;
date: string;
riskScore: number;
riskLevel: XiaobaoRiskLevel;
forecastReleaseDate?: string;
openBugCount: number;
criticalBugCount?: number;
failedTestCount: number;
blockedCount: number;
silentRiskCount: number;
confidence: number;
createdAt: string;
}
export interface RiskTrendSummary {
direction: 'up' | 'down' | 'flat' | 'unknown';
delta: number;
summary: string;
pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
currentSignature?: string;
}
const SNAPSHOT_SAVE_THROTTLE_MS = 10 * 60 * 1000;
const SNAPSHOT_SCORE_DELTA = 5;
const ONE_DAY_MS = 86_400_000;
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b));
if (sorted.length < 2) {
return { direction: 'unknown', delta: 0, summary: 'No historical risk snapshots yet.', pattern: 'unknown' };
}
const first = sorted[0];
const latest = sorted[sorted.length - 1];
const delta = clampScore(latest.riskScore) - clampScore(first.riskScore);
const recentThree = sorted.slice(-3);
if (recentThree.length === 3) {
const [a, b, c] = recentThree.map((snapshot) => clampScore(snapshot.riskScore));
if (a < b && b < c) {
return {
direction: 'up',
delta,
summary: `Risk rose continuously from ${a} to ${c}.`,
pattern: 'continuous_rising',
};
}
if (a > b && b > c) {
return {
direction: 'down',
delta,
summary: `Risk fell continuously from ${a} to ${c}.`,
pattern: 'continuous_falling',
};
}
}
if (delta >= 10) {
return { direction: 'up', delta, summary: `Risk rose by ${delta} points.`, pattern: 'score_delta' };
}
if (delta <= -10) {
return { direction: 'down', delta, summary: `Risk fell by ${Math.abs(delta)} points.`, pattern: 'score_delta' };
}
return { direction: 'flat', delta, summary: 'Risk is stable.', pattern: 'stable' };
}
export function summarizeRiskTrendWithCurrent(
snapshots: XiaobaoRiskSnapshot[] = [],
current: XiaobaoRiskSnapshot,
): RiskTrendSummary {
return {
...summarizeRiskTrend([...snapshots, current]),
currentSignature: buildRiskSignature(current),
};
}
export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
return [
snapshot.versionId,
snapshot.date,
clampScore(snapshot.riskScore),
snapshot.riskLevel,
normalizeForecastDate(snapshot.forecastReleaseDate),
snapshot.openBugCount,
snapshot.criticalBugCount ?? 0,
snapshot.failedTestCount,
snapshot.blockedCount,
snapshot.silentRiskCount,
clampScore(snapshot.confidence),
].join('|');
}
export function findLatestDailySnapshot(
snapshots: XiaobaoRiskSnapshot[],
versionId: string,
date: string,
): XiaobaoRiskSnapshot | undefined {
return snapshots
.filter((snapshot) => snapshot.versionId === versionId && snapshot.date === date)
.sort((a, b) => getSnapshotTime(b) - getSnapshotTime(a))[0];
}
export function shouldSaveRiskSnapshot(
current: XiaobaoRiskSnapshot,
previous?: XiaobaoRiskSnapshot,
now: Date = new Date(),
): boolean {
if (!previous) return true;
if (previous.versionId !== current.versionId || previous.date !== current.date) return true;
if (buildRiskSignature(current) === buildRiskSignature(previous)) return false;
if (hasMaterialSnapshotChange(current, previous)) return true;
const previousTime = getSnapshotTime(previous);
const nowTime = now.getTime();
if (!Number.isFinite(previousTime) || !Number.isFinite(nowTime)) return true;
return nowTime - previousTime >= SNAPSHOT_SAVE_THROTTLE_MS;
}
function hasMaterialSnapshotChange(current: XiaobaoRiskSnapshot, previous: XiaobaoRiskSnapshot): boolean {
if (current.riskLevel !== previous.riskLevel) return true;
if (Math.abs(clampScore(current.riskScore) - clampScore(previous.riskScore)) >= SNAPSHOT_SCORE_DELTA) return true;
if ((current.criticalBugCount ?? 0) !== (previous.criticalBugCount ?? 0)) return true;
if (current.failedTestCount !== previous.failedTestCount) return true;
if (current.blockedCount !== previous.blockedCount) return true;
if (current.silentRiskCount !== previous.silentRiskCount) return true;
return hasForecastShiftedByOneDay(current.forecastReleaseDate, previous.forecastReleaseDate);
}
function hasForecastShiftedByOneDay(current?: string, previous?: string): boolean {
if (!current && !previous) return false;
if (!current || !previous) return true;
const delta = Math.abs(new Date(current).getTime() - new Date(previous).getTime());
return Number.isFinite(delta) && delta >= ONE_DAY_MS;
}
function normalizeForecastDate(value?: string): string {
if (!value) return '';
const raw = value.trim();
if (!raw) return '';
if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return raw.slice(0, 10);
const time = new Date(raw).getTime();
if (!Number.isFinite(time)) return raw;
return new Date(time).toISOString().slice(0, 10);
}
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
return Number.isFinite(date) ? date : 0;
}
function clampScore(score: number): number {
return Math.max(0, Math.min(100, Math.round(score)));
}