fix(team): refine task duration timeline
This commit is contained in:
parent
8a62aebb3d
commit
9d7542e9c4
7 changed files with 335 additions and 61 deletions
|
|
@ -30796,6 +30796,8 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
|
||||
|
||||
try {
|
||||
const allInboxNames = Array.from(
|
||||
new Set(
|
||||
|
|
@ -30820,7 +30822,14 @@ export class TeamProvisioningService {
|
|||
return !inboxNameSetLower.has(match[1].toLowerCase());
|
||||
});
|
||||
if (inboxNames.length > 0) {
|
||||
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
|
||||
const configHasOpenCodeMember = configMembers.some((member) => {
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
const model = typeof member.model === 'string' ? member.model.trim() : '';
|
||||
return providerId === 'opencode' || inferTeamProviderIdFromModel(model) === 'opencode';
|
||||
});
|
||||
if (configHasOpenCodeMember) {
|
||||
return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId);
|
||||
}
|
||||
const configMembersByName = new Map(
|
||||
configMembers.map((member) => [member.name.toLowerCase(), member] as const)
|
||||
);
|
||||
|
|
@ -30878,54 +30887,8 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
|
||||
if (configMembers.length > 0) {
|
||||
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
|
||||
return {
|
||||
level: 'unsafe',
|
||||
rosterSource: 'config',
|
||||
members: [],
|
||||
warnings: [],
|
||||
blockers: [
|
||||
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({
|
||||
leadProviderId,
|
||||
members: configMembers,
|
||||
hasOpenCodeRuntimeAdapter: true,
|
||||
});
|
||||
if (this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan)) {
|
||||
const sideLanesHaveExplicitProviderModels = lanePlan.sideLanes.every(
|
||||
(lane) =>
|
||||
normalizeOptionalTeamProviderId(lane.member.providerId) === 'opencode' &&
|
||||
typeof lane.member.model === 'string' &&
|
||||
lane.member.model.trim().length > 0
|
||||
);
|
||||
if (!sideLanesHaveExplicitProviderModels) {
|
||||
return {
|
||||
level: 'unsafe',
|
||||
rosterSource: 'config',
|
||||
members: [],
|
||||
warnings: [],
|
||||
blockers: [
|
||||
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
level: 'repairable',
|
||||
rosterSource: 'config',
|
||||
members: configMembers,
|
||||
warnings: [
|
||||
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
|
||||
'Run a fresh team bootstrap to persist stable member metadata.',
|
||||
],
|
||||
blockers: [],
|
||||
repairAction: 'materialize-members-meta',
|
||||
};
|
||||
return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId);
|
||||
}
|
||||
|
||||
let configParseFailed = false;
|
||||
|
|
@ -30949,6 +30912,59 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
private buildConfigLaunchCompatibilityReport(
|
||||
teamName: string,
|
||||
configMembers: TeamCreateRequest['members'],
|
||||
leadProviderId?: TeamProviderId
|
||||
): TeamLaunchCompatibilityReport {
|
||||
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
|
||||
return {
|
||||
level: 'unsafe',
|
||||
rosterSource: 'config',
|
||||
members: [],
|
||||
warnings: [],
|
||||
blockers: [
|
||||
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({
|
||||
leadProviderId,
|
||||
members: configMembers,
|
||||
hasOpenCodeRuntimeAdapter: true,
|
||||
});
|
||||
if (this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan)) {
|
||||
const sideLanesHaveExplicitProviderModels = lanePlan.sideLanes.every(
|
||||
(lane) =>
|
||||
normalizeOptionalTeamProviderId(lane.member.providerId) === 'opencode' &&
|
||||
typeof lane.member.model === 'string' &&
|
||||
lane.member.model.trim().length > 0
|
||||
);
|
||||
if (!sideLanesHaveExplicitProviderModels) {
|
||||
return {
|
||||
level: 'unsafe',
|
||||
rosterSource: 'config',
|
||||
members: [],
|
||||
warnings: [],
|
||||
blockers: [
|
||||
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
level: 'repairable',
|
||||
rosterSource: 'config',
|
||||
members: configMembers,
|
||||
warnings: [
|
||||
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
|
||||
'Run a fresh team bootstrap to persist stable member metadata.',
|
||||
],
|
||||
blockers: [],
|
||||
repairAction: 'materialize-members-meta',
|
||||
};
|
||||
}
|
||||
|
||||
private buildLaunchMembersFromMeta(metaMembers: TeamMember[]): TeamCreateRequest['members'] {
|
||||
const byName = new Map<string, TeamCreateRequest['members'][number]>();
|
||||
for (const member of metaMembers) {
|
||||
|
|
|
|||
|
|
@ -6,17 +6,38 @@ import {
|
|||
TASK_STATUS_LABELS,
|
||||
TASK_STATUS_STYLES,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
calculateTaskImplementationEventDuration,
|
||||
formatTaskImplementationDuration,
|
||||
} from '@shared/utils/taskWorkDuration';
|
||||
import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck, UserRound } from 'lucide-react';
|
||||
|
||||
import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types';
|
||||
import type {
|
||||
TaskHistoryEvent,
|
||||
TaskWorkInterval,
|
||||
TeamReviewState,
|
||||
TeamTaskStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
interface WorkflowTimelineProps {
|
||||
events: TaskHistoryEvent[];
|
||||
/** Map of member name → color name for colored badges. */
|
||||
memberColorMap?: Map<string, string>;
|
||||
implementationDurationTask?: {
|
||||
status?: string | null;
|
||||
workIntervals?: TaskWorkInterval[] | null;
|
||||
} | null;
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelineProps) => {
|
||||
export const WorkflowTimeline = ({
|
||||
events,
|
||||
memberColorMap,
|
||||
implementationDurationTask,
|
||||
nowMs,
|
||||
}: WorkflowTimelineProps): React.JSX.Element => {
|
||||
const implementationNowMs = nowMs ?? 0;
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
|
|
@ -30,6 +51,13 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro
|
|||
{events.map((event, idx) => {
|
||||
const isLast = idx === events.length - 1;
|
||||
const time = formatTime(event.timestamp);
|
||||
const implementationDuration = implementationDurationTask
|
||||
? calculateTaskImplementationEventDuration(
|
||||
implementationDurationTask,
|
||||
event,
|
||||
implementationNowMs
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={event.id} className="flex">
|
||||
|
|
@ -47,6 +75,19 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro
|
|||
{time}
|
||||
</span>
|
||||
<EventContent event={event} memberColorMap={memberColorMap} />
|
||||
{implementationDuration ? (
|
||||
<span
|
||||
className="shrink-0 rounded bg-[var(--color-bg-secondary)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-muted)]"
|
||||
title={
|
||||
implementationDuration.running
|
||||
? 'Current implementation interval'
|
||||
: 'Implementation interval ended at this transition'
|
||||
}
|
||||
>
|
||||
{implementationDuration.running ? 'running ' : ''}
|
||||
{formatTaskImplementationDuration(implementationDuration.elapsedMs)}
|
||||
</span>
|
||||
) : null}
|
||||
{shouldShowTrailingActor(event) && event.actor ? (
|
||||
<span className="ml-auto shrink-0">
|
||||
<MemberBadge
|
||||
|
|
@ -78,7 +119,7 @@ const EventContent = ({
|
|||
}: {
|
||||
event: TaskHistoryEvent;
|
||||
memberColorMap?: Map<string, string>;
|
||||
}) => {
|
||||
}): React.JSX.Element => {
|
||||
switch (event.type) {
|
||||
case 'task_created':
|
||||
return (
|
||||
|
|
@ -196,7 +237,7 @@ const EventContent = ({
|
|||
}
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }: { status: TeamTaskStatus }) => {
|
||||
const StatusBadge = ({ status }: { status: TeamTaskStatus }): React.JSX.Element => {
|
||||
const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending;
|
||||
const label = TASK_STATUS_LABELS[status] ?? status;
|
||||
return (
|
||||
|
|
@ -208,7 +249,7 @@ const StatusBadge = ({ status }: { status: TeamTaskStatus }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const ReviewStateBadge = ({ state }: { state: TeamReviewState }) => {
|
||||
const ReviewStateBadge = ({ state }: { state: TeamReviewState }): React.JSX.Element | null => {
|
||||
if (state === 'none') return null;
|
||||
const display = REVIEW_STATE_DISPLAY[state];
|
||||
if (!display) return null;
|
||||
|
|
|
|||
|
|
@ -1374,7 +1374,7 @@ export const TaskDetailDialog = ({
|
|||
headerExtra={
|
||||
showTaskImplementationDuration ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
className="inline-flex items-center gap-1 rounded-md bg-[var(--color-bg-secondary)] px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
title="Implementation time from persisted work intervals"
|
||||
>
|
||||
<Clock size={10} />
|
||||
|
|
@ -1384,7 +1384,12 @@ export const TaskDetailDialog = ({
|
|||
}
|
||||
defaultOpen={false}
|
||||
>
|
||||
<WorkflowTimeline events={currentTask.historyEvents} memberColorMap={colorMap} />
|
||||
<WorkflowTimeline
|
||||
events={currentTask.historyEvents}
|
||||
memberColorMap={colorMap}
|
||||
implementationDurationTask={currentTask}
|
||||
nowMs={taskDurationNowMs}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@ interface TaskWorkDurationIntervalLike {
|
|||
completedAt?: string | null;
|
||||
}
|
||||
|
||||
interface TaskWorkDurationEventLike {
|
||||
id?: string | null;
|
||||
type?: string | null;
|
||||
timestamp?: string | null;
|
||||
status?: string | null;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskWorkDurationLike<
|
||||
TInterval extends TaskWorkDurationIntervalLike = TaskWorkDurationIntervalLike,
|
||||
> {
|
||||
|
|
@ -16,12 +25,23 @@ export interface TaskImplementationDuration {
|
|||
countedIntervalCount: number;
|
||||
}
|
||||
|
||||
export interface TaskImplementationEventDuration {
|
||||
elapsedMs: number;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
const TIMELINE_EVENT_MATCH_TOLERANCE_MS = 5_000;
|
||||
|
||||
function parseIsoMs(value: string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function isNearTime(leftMs: number, rightMs: number): boolean {
|
||||
return Math.abs(leftMs - rightMs) <= TIMELINE_EVENT_MATCH_TOLERANCE_MS;
|
||||
}
|
||||
|
||||
export function calculateTaskImplementationDuration<TInterval extends TaskWorkDurationIntervalLike>(
|
||||
task: TaskWorkDurationLike<TInterval> | null | undefined,
|
||||
nowMs = Date.now()
|
||||
|
|
@ -69,6 +89,55 @@ export function calculateTaskImplementationDuration<TInterval extends TaskWorkDu
|
|||
return { elapsedMs, hasRunningInterval, countedIntervalCount: windows.length };
|
||||
}
|
||||
|
||||
export function calculateTaskImplementationEventDuration<
|
||||
TInterval extends TaskWorkDurationIntervalLike,
|
||||
>(
|
||||
task: TaskWorkDurationLike<TInterval> | null | undefined,
|
||||
event: TaskWorkDurationEventLike,
|
||||
nowMs = Date.now()
|
||||
): TaskImplementationEventDuration | null {
|
||||
if (!task || !Array.isArray(task.workIntervals)) return null;
|
||||
|
||||
const eventMs = parseIsoMs(event.timestamp);
|
||||
if (eventMs <= 0) return null;
|
||||
|
||||
if (
|
||||
event.type === 'status_changed' &&
|
||||
event.from === 'in_progress' &&
|
||||
event.to !== 'in_progress'
|
||||
) {
|
||||
let closest: { elapsedMs: number; distanceMs: number } | null = null;
|
||||
|
||||
for (const interval of task.workIntervals) {
|
||||
const startMs = parseIsoMs(interval?.startedAt);
|
||||
const endMs = parseIsoMs(interval?.completedAt);
|
||||
if (startMs <= 0 || endMs <= startMs || !isNearTime(endMs, eventMs)) continue;
|
||||
|
||||
const distanceMs = Math.abs(endMs - eventMs);
|
||||
if (!closest || distanceMs < closest.distanceMs) {
|
||||
closest = { elapsedMs: endMs - startMs, distanceMs };
|
||||
}
|
||||
}
|
||||
|
||||
return closest ? { elapsedMs: closest.elapsedMs, running: false } : null;
|
||||
}
|
||||
|
||||
const startsInProgress =
|
||||
(event.type === 'task_created' && event.status === 'in_progress') ||
|
||||
(event.type === 'status_changed' && event.to === 'in_progress');
|
||||
|
||||
if (!startsInProgress || task.status !== 'in_progress') return null;
|
||||
|
||||
for (const interval of task.workIntervals) {
|
||||
const startMs = parseIsoMs(interval?.startedAt);
|
||||
if (startMs > 0 && !interval?.completedAt && nowMs > startMs && isNearTime(startMs, eventMs)) {
|
||||
return { elapsedMs: nowMs - startMs, running: true };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldShowTaskImplementationDuration(
|
||||
duration: TaskImplementationDuration
|
||||
): boolean {
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('rejects inbox fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => {
|
||||
it('rejects inbox fallback when OpenCode metadata is incomplete without members.meta truth', async () => {
|
||||
const svc = new TeamProvisioningService(
|
||||
{} as never,
|
||||
{ listInboxNames: vi.fn(async () => ['tom']) } as never,
|
||||
|
|
@ -163,7 +163,7 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
|||
|
||||
const configRaw = JSON.stringify({
|
||||
name: 't',
|
||||
members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }],
|
||||
members: [{ name: 'tom', role: 'developer', provider: 'opencode' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
|
@ -195,6 +195,27 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('prefers complete mixed OpenCode config over inbox names when members.meta is missing', async () => {
|
||||
const svc = new TeamProvisioningService(
|
||||
{} as never,
|
||||
{ listInboxNames: vi.fn(async () => ['tom']) } as never,
|
||||
{ getMembers: vi.fn(async () => []) } as never,
|
||||
{} as never
|
||||
);
|
||||
|
||||
const configRaw = JSON.stringify({
|
||||
name: 't',
|
||||
members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }],
|
||||
});
|
||||
|
||||
const report = await (svc as unknown as any).probeLaunchCompatibility('t', configRaw, 'codex');
|
||||
expect(report).toMatchObject({
|
||||
level: 'repairable',
|
||||
rosterSource: 'config',
|
||||
repairAction: 'materialize-members-meta',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects mixed OpenCode config fallback when the side lane is missing an explicit model', async () => {
|
||||
const svc = new TeamProvisioningService(
|
||||
{} as never,
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({
|
|||
? React.createElement('span', { 'data-testid': `section-extra-${title}` }, headerExtra)
|
||||
: null
|
||||
),
|
||||
title === 'Changes' && open ? React.createElement('div', null, children) : null
|
||||
(title === 'Changes' || title === 'Workflow History') && open
|
||||
? React.createElement('div', null, children)
|
||||
: null
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
|
@ -561,7 +563,7 @@ describe('TaskDetailDialog changes summary loading', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows total implementation time in the workflow history header', async () => {
|
||||
it('shows total and per-transition implementation time in workflow history', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-20T10:07:30.000Z'));
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
|
@ -578,9 +580,33 @@ describe('TaskDetailDialog changes summary loading', () => {
|
|||
historyEvents: [
|
||||
{
|
||||
id: 'event-created',
|
||||
timestamp: '2026-04-20T10:00:00.000Z',
|
||||
timestamp: '2026-04-20T09:59:00.000Z',
|
||||
type: 'task_created',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
actor: 'lead',
|
||||
},
|
||||
{
|
||||
id: 'event-started',
|
||||
timestamp: '2026-04-20T10:00:00.000Z',
|
||||
type: 'status_changed',
|
||||
from: 'pending',
|
||||
to: 'in_progress',
|
||||
actor: 'lead',
|
||||
},
|
||||
{
|
||||
id: 'event-completed',
|
||||
timestamp: '2026-04-20T10:02:31.000Z',
|
||||
type: 'status_changed',
|
||||
from: 'in_progress',
|
||||
to: 'completed',
|
||||
actor: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'event-restarted',
|
||||
timestamp: '2026-04-20T10:05:00.000Z',
|
||||
type: 'status_changed',
|
||||
from: 'completed',
|
||||
to: 'in_progress',
|
||||
actor: 'lead',
|
||||
},
|
||||
],
|
||||
|
|
@ -607,6 +633,21 @@ describe('TaskDetailDialog changes summary loading', () => {
|
|||
expect(host.textContent).toContain('Workflow History');
|
||||
expect(host.textContent).toContain('Work time 5m 00s');
|
||||
|
||||
const workflowButton = [...host.querySelectorAll('button')].find(
|
||||
(button) => button.textContent?.startsWith('Workflow History') === true
|
||||
);
|
||||
if (!workflowButton) {
|
||||
throw new Error('Workflow History section button not found');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
workflowButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('2m 30s');
|
||||
expect(host.textContent).toContain('running 2m 30s');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateTaskImplementationEventDuration,
|
||||
calculateTaskImplementationDuration,
|
||||
formatTaskImplementationDuration,
|
||||
shouldShowTaskImplementationDuration,
|
||||
|
|
@ -74,6 +75,86 @@ describe('taskWorkDuration', () => {
|
|||
expect(duration.countedIntervalCount).toBe(2);
|
||||
});
|
||||
|
||||
it('matches a closed interval to the status transition that ended implementation', () => {
|
||||
const duration = calculateTaskImplementationEventDuration(
|
||||
{
|
||||
status: 'completed',
|
||||
workIntervals: [
|
||||
{
|
||||
startedAt: '2026-05-08T10:00:00.000Z',
|
||||
completedAt: '2026-05-08T10:02:30.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event-completed',
|
||||
timestamp: '2026-05-08T10:02:32.000Z',
|
||||
type: 'status_changed',
|
||||
from: 'in_progress',
|
||||
to: 'completed',
|
||||
}
|
||||
);
|
||||
|
||||
expect(duration).toEqual({ elapsedMs: 150_000, running: false });
|
||||
});
|
||||
|
||||
it('shows a running interval only on the event that started the active implementation', () => {
|
||||
const task = {
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-05-08T10:05:00.000Z' }],
|
||||
};
|
||||
|
||||
expect(
|
||||
calculateTaskImplementationEventDuration(
|
||||
task,
|
||||
{
|
||||
id: 'event-started',
|
||||
timestamp: '2026-05-08T10:05:00.000Z',
|
||||
type: 'status_changed',
|
||||
from: 'completed',
|
||||
to: 'in_progress',
|
||||
},
|
||||
Date.parse('2026-05-08T10:07:30.000Z')
|
||||
)
|
||||
).toEqual({ elapsedMs: 150_000, running: true });
|
||||
|
||||
expect(
|
||||
calculateTaskImplementationEventDuration(
|
||||
task,
|
||||
{
|
||||
id: 'event-created',
|
||||
timestamp: '2026-05-08T10:00:00.000Z',
|
||||
type: 'task_created',
|
||||
status: 'pending',
|
||||
},
|
||||
Date.parse('2026-05-08T10:07:30.000Z')
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not derive transition durations from history gaps without a matching work interval', () => {
|
||||
const duration = calculateTaskImplementationEventDuration(
|
||||
{
|
||||
status: 'completed',
|
||||
workIntervals: [
|
||||
{
|
||||
startedAt: '2026-05-08T10:00:00.000Z',
|
||||
completedAt: '2026-05-08T10:02:30.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event-comment',
|
||||
timestamp: '2026-05-08T10:20:00.000Z',
|
||||
type: 'status_changed',
|
||||
from: 'in_progress',
|
||||
to: 'completed',
|
||||
}
|
||||
);
|
||||
|
||||
expect(duration).toBeNull();
|
||||
});
|
||||
|
||||
it('formats seconds, minutes, and hours for compact UI labels', () => {
|
||||
expect(formatTaskImplementationDuration(42_900)).toBe('42s');
|
||||
expect(formatTaskImplementationDuration(65_000)).toBe('1m 05s');
|
||||
|
|
|
|||
Loading…
Reference in a new issue