feat(小宝预警): 同步今日日报证据
This commit is contained in:
@@ -33,10 +33,13 @@ function createRiskRequest(patch: Partial<AgentRiskInterpretRequest> = {}): Agen
|
|||||||
dailyEvidence: {
|
dailyEvidence: {
|
||||||
todayDeliveries: [],
|
todayDeliveries: [],
|
||||||
todayProgress: [],
|
todayProgress: [],
|
||||||
|
todayCreations: [],
|
||||||
todayRisks: ['tests failed'],
|
todayRisks: ['tests failed'],
|
||||||
progressNotes: [],
|
progressNotes: [],
|
||||||
needsProgressItems: [],
|
needsProgressItems: [],
|
||||||
recentActivityCount: 1,
|
recentActivityCount: 1,
|
||||||
|
totalActivityCount: 1,
|
||||||
|
todayActualHours: 2,
|
||||||
},
|
},
|
||||||
...patch,
|
...patch,
|
||||||
};
|
};
|
||||||
@@ -87,10 +90,13 @@ describe('AiService', () => {
|
|||||||
dailyEvidence: {
|
dailyEvidence: {
|
||||||
todayDeliveries: [],
|
todayDeliveries: [],
|
||||||
todayProgress: [],
|
todayProgress: [],
|
||||||
|
todayCreations: [],
|
||||||
todayRisks: ['测试不通过'],
|
todayRisks: ['测试不通过'],
|
||||||
progressNotes: [],
|
progressNotes: [],
|
||||||
needsProgressItems: [],
|
needsProgressItems: [],
|
||||||
recentActivityCount: 1,
|
recentActivityCount: 1,
|
||||||
|
totalActivityCount: 1,
|
||||||
|
todayActualHours: 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,10 +222,13 @@ describe('AiService', () => {
|
|||||||
dailyEvidence: {
|
dailyEvidence: {
|
||||||
todayDeliveries: [],
|
todayDeliveries: [],
|
||||||
todayProgress: [],
|
todayProgress: [],
|
||||||
|
todayCreations: [],
|
||||||
todayRisks: [123],
|
todayRisks: [123],
|
||||||
progressNotes: [],
|
progressNotes: [],
|
||||||
needsProgressItems: [],
|
needsProgressItems: [],
|
||||||
recentActivityCount: 1,
|
recentActivityCount: 1,
|
||||||
|
totalActivityCount: 1,
|
||||||
|
todayActualHours: 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class RiskDailyEvidenceDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
todayProgress!: string[];
|
todayProgress!: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
todayCreations!: string[];
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
todayRisks!: string[];
|
todayRisks!: string[];
|
||||||
@@ -51,6 +55,12 @@ class RiskDailyEvidenceDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
recentActivityCount!: number;
|
recentActivityCount!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
totalActivityCount!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
todayActualHours!: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
lastActivityAt?: string;
|
lastActivityAt?: string;
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionR
|
|||||||
const evidenceItems = [
|
const evidenceItems = [
|
||||||
...(evidence?.todayDeliveries ?? []),
|
...(evidence?.todayDeliveries ?? []),
|
||||||
...(evidence?.todayProgress ?? []),
|
...(evidence?.todayProgress ?? []),
|
||||||
|
...(evidence?.todayCreations ?? []),
|
||||||
...(evidence?.todayRisks ?? []),
|
...(evidence?.todayRisks ?? []),
|
||||||
...(evidence?.progressNotes ?? []),
|
...(evidence?.progressNotes ?? []),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function XiaobaoWarningDrawer({
|
|||||||
const evidenceItems = [
|
const evidenceItems = [
|
||||||
...(evidence?.todayDeliveries ?? []),
|
...(evidence?.todayDeliveries ?? []),
|
||||||
...(evidence?.todayProgress ?? []),
|
...(evidence?.todayProgress ?? []),
|
||||||
|
...(evidence?.todayCreations ?? []),
|
||||||
...(evidence?.todayRisks ?? []),
|
...(evidence?.todayRisks ?? []),
|
||||||
...(evidence?.progressNotes ?? []),
|
...(evidence?.progressNotes ?? []),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -53,10 +53,13 @@ function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
|||||||
dailyEvidence: {
|
dailyEvidence: {
|
||||||
todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core flow.', occurredAt: '2026-06-29T09:00:00.000Z' }],
|
todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core flow.', occurredAt: '2026-06-29T09:00:00.000Z' }],
|
||||||
todayProgress: [{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T10:00:00.000Z' }],
|
todayProgress: [{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T10:00:00.000Z' }],
|
||||||
|
todayCreations: [{ id: 'ev-create', title: 'Creation', summary: 'Created regression case.', occurredAt: '2026-06-29T10:30:00.000Z' }],
|
||||||
todayRisks: [{ id: 'ev-3', title: 'Risk', summary: 'Regression failed.', occurredAt: '2026-06-29T11:00:00.000Z' }],
|
todayRisks: [{ id: 'ev-3', title: 'Risk', summary: 'Regression failed.', occurredAt: '2026-06-29T11:00:00.000Z' }],
|
||||||
progressNotes: [{ id: 'ev-4', title: 'Note', summary: 'Need QA retest.', occurredAt: '2026-06-29T12:00:00.000Z' }],
|
progressNotes: [{ id: 'ev-4', title: 'Note', summary: 'Need QA retest.', occurredAt: '2026-06-29T12:00:00.000Z' }],
|
||||||
needsProgressItems: [{ id: 'ev-5', title: 'Need update', summary: 'Backend task has no update.', occurredAt: '2026-06-29T13:00:00.000Z' }],
|
needsProgressItems: [{ id: 'ev-5', title: 'Need update', summary: 'Backend task has no update.', occurredAt: '2026-06-29T13:00:00.000Z' }],
|
||||||
recentActivityCount: 3,
|
recentActivityCount: 3,
|
||||||
|
totalActivityCount: 6,
|
||||||
|
todayActualHours: 4.5,
|
||||||
lastActivityAt: '2026-06-29T13:00:00.000Z',
|
lastActivityAt: '2026-06-29T13:00:00.000Z',
|
||||||
},
|
},
|
||||||
signals: {
|
signals: {
|
||||||
@@ -437,7 +440,10 @@ test('buildRiskInterpretRequest compresses frontend risk evidence for the backen
|
|||||||
assert.equal(payload.reasons[0].severity, 'medium');
|
assert.equal(payload.reasons[0].severity, 'medium');
|
||||||
assert.equal(payload.reasons[1].severity, 'critical');
|
assert.equal(payload.reasons[1].severity, 'critical');
|
||||||
assert.deepEqual(payload.dailyEvidence.todayDeliveries, ['Submitted core flow.']);
|
assert.deepEqual(payload.dailyEvidence.todayDeliveries, ['Submitted core flow.']);
|
||||||
|
assert.deepEqual(payload.dailyEvidence.todayCreations, ['Created regression case.']);
|
||||||
assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']);
|
assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']);
|
||||||
|
assert.equal(payload.dailyEvidence.totalActivityCount, 6);
|
||||||
|
assert.equal(payload.dailyEvidence.todayActualHours, 4.5);
|
||||||
assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.');
|
assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -132,10 +132,13 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
|||||||
dailyEvidence: {
|
dailyEvidence: {
|
||||||
todayDeliveries: normalizeEvidence(risk.dailyEvidence?.todayDeliveries),
|
todayDeliveries: normalizeEvidence(risk.dailyEvidence?.todayDeliveries),
|
||||||
todayProgress: normalizeEvidence(risk.dailyEvidence?.todayProgress),
|
todayProgress: normalizeEvidence(risk.dailyEvidence?.todayProgress),
|
||||||
|
todayCreations: normalizeEvidence(risk.dailyEvidence?.todayCreations),
|
||||||
todayRisks: normalizeEvidence(risk.dailyEvidence?.todayRisks),
|
todayRisks: normalizeEvidence(risk.dailyEvidence?.todayRisks),
|
||||||
progressNotes: normalizeEvidence(risk.dailyEvidence?.progressNotes),
|
progressNotes: normalizeEvidence(risk.dailyEvidence?.progressNotes),
|
||||||
needsProgressItems: normalizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
needsProgressItems: normalizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
||||||
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
||||||
|
totalActivityCount: risk.dailyEvidence?.totalActivityCount ?? 0,
|
||||||
|
todayActualHours: risk.dailyEvidence?.todayActualHours ?? 0,
|
||||||
lastActivityAt: risk.dailyEvidence?.lastActivityAt ?? null,
|
lastActivityAt: risk.dailyEvidence?.lastActivityAt ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -185,10 +188,13 @@ export function buildRiskInterpretRequest(risk: XiaobaoVersionRisk): AgentRiskIn
|
|||||||
dailyEvidence: {
|
dailyEvidence: {
|
||||||
todayDeliveries: summarizeEvidence(risk.dailyEvidence?.todayDeliveries),
|
todayDeliveries: summarizeEvidence(risk.dailyEvidence?.todayDeliveries),
|
||||||
todayProgress: summarizeEvidence(risk.dailyEvidence?.todayProgress),
|
todayProgress: summarizeEvidence(risk.dailyEvidence?.todayProgress),
|
||||||
|
todayCreations: summarizeEvidence(risk.dailyEvidence?.todayCreations),
|
||||||
todayRisks: summarizeEvidence(risk.dailyEvidence?.todayRisks),
|
todayRisks: summarizeEvidence(risk.dailyEvidence?.todayRisks),
|
||||||
progressNotes: summarizeEvidence(risk.dailyEvidence?.progressNotes),
|
progressNotes: summarizeEvidence(risk.dailyEvidence?.progressNotes),
|
||||||
needsProgressItems: summarizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
needsProgressItems: summarizeEvidence(risk.dailyEvidence?.needsProgressItems),
|
||||||
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0,
|
||||||
|
totalActivityCount: risk.dailyEvidence?.totalActivityCount ?? 0,
|
||||||
|
todayActualHours: risk.dailyEvidence?.todayActualHours ?? 0,
|
||||||
lastActivityAt: risk.dailyEvidence?.lastActivityAt,
|
lastActivityAt: risk.dailyEvidence?.lastActivityAt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -354,9 +360,12 @@ function buildDisplayCompatibilityKey(signature: string): string | undefined {
|
|||||||
dailyEvidence: {
|
dailyEvidence: {
|
||||||
todayDeliveries: normalizeCompatibilityEvidence(dailyEvidence.todayDeliveries),
|
todayDeliveries: normalizeCompatibilityEvidence(dailyEvidence.todayDeliveries),
|
||||||
todayProgress: normalizeCompatibilityEvidence(dailyEvidence.todayProgress),
|
todayProgress: normalizeCompatibilityEvidence(dailyEvidence.todayProgress),
|
||||||
|
todayCreations: normalizeCompatibilityEvidence(dailyEvidence.todayCreations),
|
||||||
todayRisks: normalizeCompatibilityEvidence(dailyEvidence.todayRisks),
|
todayRisks: normalizeCompatibilityEvidence(dailyEvidence.todayRisks),
|
||||||
progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes),
|
progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes),
|
||||||
needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems),
|
needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems),
|
||||||
|
totalActivityCount: normalizeInteger(dailyEvidence.totalActivityCount),
|
||||||
|
todayActualHours: normalizeNumber(dailyEvidence.todayActualHours),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -427,6 +436,10 @@ function normalizeInteger(value: unknown): number | null {
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
|
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeNumber(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 10) / 10 : null;
|
||||||
|
}
|
||||||
|
|
||||||
function getTime(value: string): number {
|
function getTime(value: string): number {
|
||||||
const time = new Date(value).getTime();
|
const time = new Date(value).getTime();
|
||||||
return Number.isFinite(time) ? time : Number.NaN;
|
return Number.isFinite(time) ? time : Number.NaN;
|
||||||
|
|||||||
@@ -148,6 +148,80 @@ test('buildVersionDailyEvidence groups version activity and detects no activity/
|
|||||||
assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_activity' && risk.itemId === 'plan-1'));
|
assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_activity' && risk.itemId === 'plan-1'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildVersionDailyEvidence mirrors daily report timestamp fallback evidence', () => {
|
||||||
|
const workItems = buildXiaobaoWorkItems({
|
||||||
|
plans: [
|
||||||
|
plan({
|
||||||
|
id: 'plan-created',
|
||||||
|
title: '新建产品方案',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: '2026-06-29T01:00:00.000Z',
|
||||||
|
actualStartAt: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
devTasks: [
|
||||||
|
devTask({
|
||||||
|
id: 'dev-started',
|
||||||
|
title: '开始开发风险列表',
|
||||||
|
status: 'in_progress',
|
||||||
|
actualStartAt: '2026-06-29T02:00:00.000Z',
|
||||||
|
createdAt: '2026-06-28T01:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-29T02:00:00.000Z',
|
||||||
|
}),
|
||||||
|
devTask({
|
||||||
|
id: 'dev-submitted',
|
||||||
|
title: '提交风险列表',
|
||||||
|
status: 'submitted',
|
||||||
|
actualStartAt: '2026-06-28T02:00:00.000Z',
|
||||||
|
actualEndAt: '2026-06-29T03:00:00.000Z',
|
||||||
|
createdAt: '2026-06-28T01:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-29T03:00:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
testCases: [
|
||||||
|
testCase({
|
||||||
|
id: 'tc-failed',
|
||||||
|
title: '验证风险列表失败态',
|
||||||
|
status: 'failed',
|
||||||
|
startedAt: '2026-06-29T03:30:00.000Z',
|
||||||
|
completedAt: '2026-06-29T04:00:00.000Z',
|
||||||
|
createdAt: '2026-06-28T01:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-29T04:00:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
bugs: [
|
||||||
|
bug({
|
||||||
|
id: 'bug-fixed',
|
||||||
|
title: '风险列表数量错误',
|
||||||
|
status: 'fixed',
|
||||||
|
createdAt: '2026-06-28T01:00:00.000Z',
|
||||||
|
resolvedAt: '2026-06-29T05:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-29T05:00:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
|
||||||
|
requirementVersionMap: new Map([['req-1', 'ver-1']]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const evidence = buildVersionDailyEvidence({
|
||||||
|
versionId: 'ver-1',
|
||||||
|
workItems,
|
||||||
|
activities: [],
|
||||||
|
worklogs: [],
|
||||||
|
now: new Date('2026-06-29T08:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(evidence.todayCreations.map((item) => item.summary), ['新建计划:新建产品方案']);
|
||||||
|
assert.ok(evidence.todayProgress.some((item) => item.summary === '开始开发:开始开发风险列表'));
|
||||||
|
assert.ok(evidence.todayDeliveries.some((item) => item.summary === '已提测开发任务:提交风险列表'));
|
||||||
|
assert.ok(evidence.todayDeliveries.some((item) => item.summary === '已修复 Bug:风险列表数量错误'));
|
||||||
|
assert.ok(evidence.todayProgress.some((item) => item.summary === '开始测试:验证风险列表失败态'));
|
||||||
|
assert.ok(evidence.todayRisks.some((item) => item.summary === '测试不通过:验证风险列表失败态'));
|
||||||
|
assert.equal(evidence.totalActivityCount, 6);
|
||||||
|
assert.equal(evidence.lastActivityAt, '2026-06-29T05:00:00.000Z');
|
||||||
|
assert.ok(evidence.todayActualHours > 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildXiaobaoWorkItems keeps all version work without current-user filtering', () => {
|
test('buildXiaobaoWorkItems keeps all version work without current-user filtering', () => {
|
||||||
const items = buildXiaobaoWorkItems({
|
const items = buildXiaobaoWorkItems({
|
||||||
plans: [plan({ owner: 'pm-other' })],
|
plans: [plan({ owner: 'pm-other' })],
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import type { DevTask } from './dev-task';
|
|||||||
import type { TaskWorklog } from './task-worklog';
|
import type { TaskWorklog } from './task-worklog';
|
||||||
import type { TestCase } from './test-case';
|
import type { TestCase } from './test-case';
|
||||||
import type { VersionPlan } from './version-plan';
|
import type { VersionPlan } from './version-plan';
|
||||||
import type { WorkActivity } from './work-activity';
|
import type { WorkActivity, WorkActivityCategory } from './work-activity';
|
||||||
import type { WorkItem } from './workspace-engine';
|
import type { WorkItem } from './workspace-engine';
|
||||||
|
import { calcActualElapsedHours } from './work-hours';
|
||||||
|
|
||||||
export interface EvidenceItem {
|
export interface EvidenceItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,10 +19,13 @@ export interface EvidenceItem {
|
|||||||
export interface VersionDailyEvidence {
|
export interface VersionDailyEvidence {
|
||||||
todayDeliveries: EvidenceItem[];
|
todayDeliveries: EvidenceItem[];
|
||||||
todayProgress: EvidenceItem[];
|
todayProgress: EvidenceItem[];
|
||||||
|
todayCreations: EvidenceItem[];
|
||||||
todayRisks: EvidenceItem[];
|
todayRisks: EvidenceItem[];
|
||||||
progressNotes: EvidenceItem[];
|
progressNotes: EvidenceItem[];
|
||||||
needsProgressItems: EvidenceItem[];
|
needsProgressItems: EvidenceItem[];
|
||||||
recentActivityCount: number;
|
recentActivityCount: number;
|
||||||
|
totalActivityCount: number;
|
||||||
|
todayActualHours: number;
|
||||||
lastActivityAt?: string;
|
lastActivityAt?: string;
|
||||||
silentRisks?: SilentRisk[];
|
silentRisks?: SilentRisk[];
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,13 @@ export interface BuildXiaobaoWorkItemsInput {
|
|||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']);
|
const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']);
|
||||||
|
|
||||||
|
type EvidenceDraft = EvidenceItem & {
|
||||||
|
sourceId: string;
|
||||||
|
sourceType: WorkActivity['sourceType'];
|
||||||
|
action: WorkActivity['action'];
|
||||||
|
category: WorkActivityCategory;
|
||||||
|
};
|
||||||
|
|
||||||
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
|
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
|
||||||
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
|
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
|
||||||
const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap);
|
const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap);
|
||||||
@@ -167,39 +178,66 @@ export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput)
|
|||||||
const versionWorklogs = (input.worklogs ?? []).filter((worklog) => versionItemIds.has(worklog.taskId));
|
const versionWorklogs = (input.worklogs ?? []).filter((worklog) => versionItemIds.has(worklog.taskId));
|
||||||
const todayActivities = versionActivities.filter((activity) => activity.date === today);
|
const todayActivities = versionActivities.filter((activity) => activity.date === today);
|
||||||
const todayWorklogs = versionWorklogs.filter((worklog) => worklog.date === today);
|
const todayWorklogs = versionWorklogs.filter((worklog) => worklog.date === today);
|
||||||
|
const activityKeys = new Set(todayActivities.map(getActivityKey));
|
||||||
|
const evidenceSourceIds = new Set([
|
||||||
|
...todayActivities.map((activity) => activity.sourceId),
|
||||||
|
...todayWorklogs.map((worklog) => worklog.taskId),
|
||||||
|
]);
|
||||||
|
const fallbackEvidence = buildTimestampFallbackEvidence(versionItems, today, activityKeys, evidenceSourceIds);
|
||||||
|
const activityEvidence = [
|
||||||
|
...todayActivities.map(activityToEvidenceDraft),
|
||||||
|
...fallbackEvidence,
|
||||||
|
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||||
|
const evidenceActivities = [
|
||||||
|
...versionActivities,
|
||||||
|
...fallbackEvidence.map(evidenceDraftToActivity),
|
||||||
|
];
|
||||||
const recentSince = now.getTime() - 3 * 86_400_000;
|
const recentSince = now.getTime() - 3 * 86_400_000;
|
||||||
|
|
||||||
const recentActivityCount = [
|
const recentActivityCount = [
|
||||||
...versionActivities.map((activity) => activity.occurredAt),
|
...evidenceActivities.map((activity) => activity.occurredAt),
|
||||||
...versionWorklogs.map((worklog) => worklog.createdAt),
|
...versionWorklogs.map((worklog) => worklog.createdAt),
|
||||||
].filter((value) => {
|
].filter((value) => {
|
||||||
const date = parseDate(value);
|
const date = parseDate(value);
|
||||||
return Boolean(date && date.getTime() >= recentSince);
|
return Boolean(date && date.getTime() >= recentSince);
|
||||||
}).length;
|
}).length;
|
||||||
const lastActivityAt = latestIso([
|
const lastActivityAt = latestIso([
|
||||||
...versionActivities.map((activity) => activity.occurredAt),
|
...evidenceActivities.map((activity) => activity.occurredAt),
|
||||||
...versionWorklogs.map((worklog) => worklog.createdAt),
|
...versionWorklogs.map((worklog) => worklog.createdAt),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const touchedTodayIds = new Set([
|
const touchedTodayIds = new Set([
|
||||||
...todayActivities.map((activity) => activity.sourceId),
|
...activityEvidence.map((activity) => activity.sourceId),
|
||||||
...todayWorklogs.map((worklog) => worklog.taskId),
|
...todayWorklogs.map((worklog) => worklog.taskId),
|
||||||
]);
|
]);
|
||||||
|
const todayDeliveries = activityEvidence.filter((activity) => activity.category === 'delivery').map(evidenceDraftToEvidenceItem);
|
||||||
|
const todayProgress = activityEvidence.filter((activity) => activity.category === 'progress').map(evidenceDraftToEvidenceItem);
|
||||||
|
const todayCreations = activityEvidence.filter((activity) => activity.category === 'creation').map(evidenceDraftToEvidenceItem);
|
||||||
|
const todayRisks = activityEvidence.filter((activity) => activity.category === 'risk').map(evidenceDraftToEvidenceItem);
|
||||||
|
const progressNotes = [
|
||||||
|
...activityEvidence.filter((activity) => activity.category === 'note').map(evidenceDraftToEvidenceItem),
|
||||||
|
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
|
||||||
|
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||||
|
const worklogTaskIds = new Set(todayWorklogs.map((worklog) => worklog.taskId));
|
||||||
|
const activityHours = Array.from(new Set(activityEvidence.map((activity) => activity.sourceId)))
|
||||||
|
.filter((sourceId) => !worklogTaskIds.has(sourceId))
|
||||||
|
.reduce((sum, sourceId) => sum + calcWorkItemHoursForDate(itemMap.get(sourceId), today, now), 0);
|
||||||
|
const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
todayDeliveries: todayActivities.filter((activity) => activity.category === 'delivery').map(activityToEvidenceItem),
|
todayDeliveries,
|
||||||
todayProgress: todayActivities.filter((activity) => activity.category === 'progress').map(activityToEvidenceItem),
|
todayProgress,
|
||||||
todayRisks: todayActivities.filter((activity) => activity.category === 'risk').map(activityToEvidenceItem),
|
todayCreations,
|
||||||
progressNotes: [
|
todayRisks,
|
||||||
...todayActivities.filter((activity) => activity.category === 'note').map(activityToEvidenceItem),
|
progressNotes,
|
||||||
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
|
|
||||||
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
|
|
||||||
needsProgressItems: versionItems
|
needsProgressItems: versionItems
|
||||||
.filter((item) => shouldNeedProgress(item, touchedTodayIds, versionActivities, versionWorklogs, now))
|
.filter((item) => shouldNeedProgress(item, touchedTodayIds, evidenceActivities, versionWorklogs, now))
|
||||||
.map(itemToEvidenceItem),
|
.map(itemToEvidenceItem),
|
||||||
recentActivityCount,
|
recentActivityCount,
|
||||||
|
totalActivityCount: todayDeliveries.length + todayProgress.length + todayCreations.length + todayRisks.length + progressNotes.length,
|
||||||
|
todayActualHours: roundHalfHour(worklogHours + activityHours),
|
||||||
lastActivityAt,
|
lastActivityAt,
|
||||||
silentRisks: buildSilentRisks(versionItems, versionActivities, versionWorklogs, now),
|
silentRisks: buildSilentRisks(versionItems, evidenceActivities, versionWorklogs, now),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +255,41 @@ function activityToEvidenceItem(activity: WorkActivity): EvidenceItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activityToEvidenceDraft(activity: WorkActivity): EvidenceDraft {
|
||||||
|
return {
|
||||||
|
...activityToEvidenceItem(activity),
|
||||||
|
sourceId: activity.sourceId,
|
||||||
|
sourceType: activity.sourceType,
|
||||||
|
action: activity.action,
|
||||||
|
category: activity.category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceDraftToEvidenceItem(item: EvidenceDraft): EvidenceItem {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
summary: item.summary,
|
||||||
|
occurredAt: item.occurredAt,
|
||||||
|
actorId: item.actorId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceDraftToActivity(item: EvidenceDraft): WorkActivity {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
actorId: item.actorId ?? '',
|
||||||
|
date: item.occurredAt.slice(0, 10),
|
||||||
|
sourceType: item.sourceType,
|
||||||
|
sourceId: item.sourceId,
|
||||||
|
action: item.action,
|
||||||
|
category: item.category,
|
||||||
|
title: item.title,
|
||||||
|
summary: item.summary,
|
||||||
|
occurredAt: item.occurredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem {
|
function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem {
|
||||||
return {
|
return {
|
||||||
id: worklog.id,
|
id: worklog.id,
|
||||||
@@ -236,6 +309,109 @@ function itemToEvidenceItem(item: WorkItem): EvidenceItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTimestampFallbackEvidence(
|
||||||
|
items: WorkItem[],
|
||||||
|
date: string,
|
||||||
|
existingKeys: Set<string>,
|
||||||
|
evidenceSourceIds: Set<string>,
|
||||||
|
): EvidenceDraft[] {
|
||||||
|
const out: EvidenceDraft[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (evidenceSourceIds.has(item.id)) continue;
|
||||||
|
for (const draft of getTimestampFallbackDrafts(item)) {
|
||||||
|
if (!isWithinLocalDate(draft.occurredAt, date)) continue;
|
||||||
|
const key = `${item.id}:${draft.action}`;
|
||||||
|
if (existingKeys.has(key)) continue;
|
||||||
|
existingKeys.add(key);
|
||||||
|
out.push({
|
||||||
|
id: `fallback-${draft.action}-${item.id}`,
|
||||||
|
title: item.title,
|
||||||
|
sourceId: item.id,
|
||||||
|
sourceType: draft.sourceType,
|
||||||
|
action: draft.action,
|
||||||
|
category: draft.category,
|
||||||
|
summary: draft.summary,
|
||||||
|
occurredAt: draft.occurredAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimestampFallbackDrafts(
|
||||||
|
item: WorkItem,
|
||||||
|
): Array<Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'>> {
|
||||||
|
const raw = getRaw(item);
|
||||||
|
const extra = item.extra ?? {};
|
||||||
|
|
||||||
|
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
|
||||||
|
return [
|
||||||
|
makeFallbackDraft('version_plan', 'version_plan_created', 'creation', asString(raw.createdAt), `新建计划:${item.title}`),
|
||||||
|
makeFallbackDraft('version_plan', 'version_plan_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始计划:${item.title}`),
|
||||||
|
makeFallbackDraft('version_plan', 'version_plan_completed', 'delivery', asString(raw.completedAt), `完成计划:${item.title}`),
|
||||||
|
].filter(isFallbackDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'devTask') {
|
||||||
|
return [
|
||||||
|
makeFallbackDraft('dev_task', 'dev_task_created', 'creation', asString(raw.createdAt), `新建开发任务:${item.title}`),
|
||||||
|
makeFallbackDraft('dev_task', 'dev_task_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始开发:${item.title}`),
|
||||||
|
makeFallbackDraft('dev_task', 'dev_task_submitted', 'delivery', asString(raw.actualEndAt) ?? asString(extra.actualEndAt), `已提测开发任务:${item.title}`),
|
||||||
|
].filter(isFallbackDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'testCase') {
|
||||||
|
const completedAt = asString(raw.completedAt) ?? asString(extra.completedAt);
|
||||||
|
const terminal = getTestCaseTerminalFallback(item.status, item.title, completedAt);
|
||||||
|
return [
|
||||||
|
makeFallbackDraft('test_case', 'test_case_created', 'creation', asString(raw.createdAt), `新建测试用例:${item.title}`),
|
||||||
|
makeFallbackDraft('test_case', 'test_case_started', 'progress', asString(raw.startedAt) ?? asString(extra.startedAt), `开始测试:${item.title}`),
|
||||||
|
terminal,
|
||||||
|
].filter(isFallbackDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'bug') {
|
||||||
|
return [
|
||||||
|
makeFallbackDraft('bug', 'bug_created', 'creation', asString(raw.createdAt), `新建 Bug:${item.title}`),
|
||||||
|
makeFallbackDraft('bug', 'bug_fixing', 'progress', getBugFixingStartedAt(raw), `开始修复 Bug:${item.title}`),
|
||||||
|
makeFallbackDraft('bug', 'bug_fixed', 'delivery', asString(raw.resolvedAt), `已修复 Bug:${item.title}`),
|
||||||
|
makeFallbackDraft('bug', 'bug_closed', 'delivery', asString(raw.closedAt), `已关闭 Bug:${item.title}`),
|
||||||
|
].filter(isFallbackDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFallbackDraft(
|
||||||
|
sourceType: WorkActivity['sourceType'],
|
||||||
|
action: WorkActivity['action'],
|
||||||
|
category: WorkActivityCategory,
|
||||||
|
occurredAt: string | undefined,
|
||||||
|
summary: string,
|
||||||
|
): Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
|
||||||
|
if (!occurredAt) return undefined;
|
||||||
|
return { sourceType, action, category, summary, occurredAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFallbackDraft(
|
||||||
|
draft: Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined,
|
||||||
|
): draft is Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> {
|
||||||
|
return Boolean(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTestCaseTerminalFallback(
|
||||||
|
status: string,
|
||||||
|
title: string,
|
||||||
|
completedAt: string | undefined,
|
||||||
|
): Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
|
||||||
|
if (status === 'passed') return makeFallbackDraft('test_case', 'test_case_passed', 'delivery', completedAt, `测试通过:${title}`);
|
||||||
|
if (status === 'failed') return makeFallbackDraft('test_case', 'test_case_failed', 'risk', completedAt, `测试不通过:${title}`);
|
||||||
|
if (status === 'blocked') return makeFallbackDraft('test_case', 'test_case_blocked', 'risk', completedAt, `测试阻塞:${title}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSilentRisks(
|
function buildSilentRisks(
|
||||||
items: WorkItem[],
|
items: WorkItem[],
|
||||||
activities: WorkActivity[],
|
activities: WorkActivity[],
|
||||||
@@ -328,6 +504,102 @@ function getLatestItemTouchAt(item: WorkItem): string | undefined {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number {
|
||||||
|
const interval = getActualInterval(item, now);
|
||||||
|
if (!interval) return 0;
|
||||||
|
|
||||||
|
const day = getLocalDateBounds(date);
|
||||||
|
if (!day) return 0;
|
||||||
|
|
||||||
|
const start = new Date(interval.start).getTime();
|
||||||
|
const end = new Date(interval.end).getTime();
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
|
||||||
|
|
||||||
|
const overlapStart = Math.max(start, day.start.getTime());
|
||||||
|
const overlapEnd = Math.min(end, day.end.getTime());
|
||||||
|
if (overlapEnd <= overlapStart) return 0;
|
||||||
|
|
||||||
|
return calcActualElapsedHours(
|
||||||
|
new Date(overlapStart).toISOString(),
|
||||||
|
new Date(overlapEnd).toISOString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {
|
||||||
|
if (!item) return undefined;
|
||||||
|
const raw = getRaw(item);
|
||||||
|
const extra = item.extra ?? {};
|
||||||
|
const nowIso = now.toISOString();
|
||||||
|
|
||||||
|
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
|
||||||
|
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
|
||||||
|
const end = asString(raw.completedAt) ?? nowIso;
|
||||||
|
return start ? { start, end } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'devTask') {
|
||||||
|
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
|
||||||
|
const end = asString(raw.actualEndAt) ?? asString(extra.actualEndAt) ?? (item.status === 'submitted' ? asString(raw.updatedAt) : undefined) ?? nowIso;
|
||||||
|
return start ? { start, end } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'testCase') {
|
||||||
|
const start = asString(raw.startedAt) ?? asString(extra.startedAt);
|
||||||
|
const isTerminal = ['passed', 'failed', 'blocked'].includes(item.status);
|
||||||
|
const end = asString(raw.completedAt) ?? asString(extra.completedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
|
||||||
|
return start ? { start, end } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'bug') {
|
||||||
|
const start = getBugFixingStartedAt(raw) ?? asString(raw.createdAt) ?? asString(extra.createdAt);
|
||||||
|
const isTerminal = ['closed', 'rejected'].includes(item.status);
|
||||||
|
const end = asString(raw.closedAt) ?? asString(raw.resolvedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
|
||||||
|
return start ? { start, end } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBugFixingStartedAt(raw: Record<string, unknown>): string | undefined {
|
||||||
|
const logs = raw.logs;
|
||||||
|
if (!Array.isArray(logs)) return undefined;
|
||||||
|
const fixingLog = logs.find((log) => {
|
||||||
|
if (!log || typeof log !== 'object') return false;
|
||||||
|
const row = log as Record<string, unknown>;
|
||||||
|
return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string';
|
||||||
|
}) as Record<string, unknown> | undefined;
|
||||||
|
return asString(fixingLog?.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityKey(activity: Pick<WorkActivity, 'sourceId' | 'action'>): string {
|
||||||
|
return `${activity.sourceId}:${activity.action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRaw(item: WorkItem): Record<string, unknown> {
|
||||||
|
return ((item.raw ?? {}) as unknown) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalDateBounds(date: string): { start: Date; end: Date } | undefined {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return undefined;
|
||||||
|
const start = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
|
const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0);
|
||||||
|
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return undefined;
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinLocalDate(value: string, date: string): boolean {
|
||||||
|
const bounds = getLocalDateBounds(date);
|
||||||
|
if (!bounds) return false;
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
if (!Number.isFinite(time)) return false;
|
||||||
|
return time >= bounds.start.getTime() && time < bounds.end.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
function latestIso(values: Array<string | undefined>): string | undefined {
|
function latestIso(values: Array<string | undefined>): string | undefined {
|
||||||
let latest: string | undefined;
|
let latest: string | undefined;
|
||||||
let latestTime = Number.NEGATIVE_INFINITY;
|
let latestTime = Number.NEGATIVE_INFINITY;
|
||||||
@@ -358,3 +630,8 @@ function parseDate(value: string | undefined): Date | undefined {
|
|||||||
function asString(value: unknown): string | undefined {
|
function asString(value: unknown): string | undefined {
|
||||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roundHalfHour(hours: number): number {
|
||||||
|
if (!Number.isFinite(hours) || hours <= 0) return 0;
|
||||||
|
return Math.round(hours * 2) / 2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,10 +110,13 @@ test('calcXiaobaoVersionRisk preserves daily evidence on the returned risk objec
|
|||||||
const dailyEvidence: VersionDailyEvidence = {
|
const dailyEvidence: VersionDailyEvidence = {
|
||||||
todayDeliveries: [{ id: 'ev-1', title: '提交提测', summary: '完成核心流程', occurredAt: '2026-07-02T02:00:00.000Z' }],
|
todayDeliveries: [{ id: 'ev-1', title: '提交提测', summary: '完成核心流程', occurredAt: '2026-07-02T02:00:00.000Z' }],
|
||||||
todayProgress: [],
|
todayProgress: [],
|
||||||
|
todayCreations: [],
|
||||||
todayRisks: [],
|
todayRisks: [],
|
||||||
progressNotes: [],
|
progressNotes: [],
|
||||||
needsProgressItems: [],
|
needsProgressItems: [],
|
||||||
recentActivityCount: 1,
|
recentActivityCount: 1,
|
||||||
|
totalActivityCount: 1,
|
||||||
|
todayActualHours: 2,
|
||||||
lastActivityAt: '2026-07-02T02:00:00.000Z',
|
lastActivityAt: '2026-07-02T02:00:00.000Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,10 +143,13 @@ test('calcXiaobaoVersionRisk uses silent risks from daily evidence', () => {
|
|||||||
const dailyEvidence: VersionDailyEvidence = {
|
const dailyEvidence: VersionDailyEvidence = {
|
||||||
todayDeliveries: [],
|
todayDeliveries: [],
|
||||||
todayProgress: [],
|
todayProgress: [],
|
||||||
|
todayCreations: [],
|
||||||
todayRisks: [],
|
todayRisks: [],
|
||||||
progressNotes: [],
|
progressNotes: [],
|
||||||
needsProgressItems: [],
|
needsProgressItems: [],
|
||||||
recentActivityCount: 0,
|
recentActivityCount: 0,
|
||||||
|
totalActivityCount: 0,
|
||||||
|
todayActualHours: 0,
|
||||||
silentRisks: [silentRisk],
|
silentRisks: [silentRisk],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -147,10 +147,13 @@ export interface AgentRiskInterpretRequest {
|
|||||||
dailyEvidence: {
|
dailyEvidence: {
|
||||||
todayDeliveries: string[];
|
todayDeliveries: string[];
|
||||||
todayProgress: string[];
|
todayProgress: string[];
|
||||||
|
todayCreations: string[];
|
||||||
todayRisks: string[];
|
todayRisks: string[];
|
||||||
progressNotes: string[];
|
progressNotes: string[];
|
||||||
needsProgressItems: string[];
|
needsProgressItems: string[];
|
||||||
recentActivityCount: number;
|
recentActivityCount: number;
|
||||||
|
totalActivityCount: number;
|
||||||
|
todayActualHours: number;
|
||||||
lastActivityAt?: string;
|
lastActivityAt?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user