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 {
|
try {
|
||||||
const allInboxNames = Array.from(
|
const allInboxNames = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
|
|
@ -30820,7 +30822,14 @@ export class TeamProvisioningService {
|
||||||
return !inboxNameSetLower.has(match[1].toLowerCase());
|
return !inboxNameSetLower.has(match[1].toLowerCase());
|
||||||
});
|
});
|
||||||
if (inboxNames.length > 0) {
|
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(
|
const configMembersByName = new Map(
|
||||||
configMembers.map((member) => [member.name.toLowerCase(), member] as const)
|
configMembers.map((member) => [member.name.toLowerCase(), member] as const)
|
||||||
);
|
);
|
||||||
|
|
@ -30878,8 +30887,36 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
|
|
||||||
if (configMembers.length > 0) {
|
if (configMembers.length > 0) {
|
||||||
|
return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let configParseFailed = false;
|
||||||
|
try {
|
||||||
|
JSON.parse(configRaw);
|
||||||
|
} catch {
|
||||||
|
configParseFailed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: 'ready',
|
||||||
|
rosterSource: 'missing',
|
||||||
|
members: [],
|
||||||
|
warnings: configParseFailed
|
||||||
|
? [
|
||||||
|
'Config could not be parsed during launch roster discovery. ' +
|
||||||
|
'Launch will continue without explicit teammate names.',
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
blockers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConfigLaunchCompatibilityReport(
|
||||||
|
teamName: string,
|
||||||
|
configMembers: TeamCreateRequest['members'],
|
||||||
|
leadProviderId?: TeamProviderId
|
||||||
|
): TeamLaunchCompatibilityReport {
|
||||||
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
|
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
|
||||||
return {
|
return {
|
||||||
level: 'unsafe',
|
level: 'unsafe',
|
||||||
|
|
@ -30928,27 +30965,6 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let configParseFailed = false;
|
|
||||||
try {
|
|
||||||
JSON.parse(configRaw);
|
|
||||||
} catch {
|
|
||||||
configParseFailed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
level: 'ready',
|
|
||||||
rosterSource: 'missing',
|
|
||||||
members: [],
|
|
||||||
warnings: configParseFailed
|
|
||||||
? [
|
|
||||||
'Config could not be parsed during launch roster discovery. ' +
|
|
||||||
'Launch will continue without explicit teammate names.',
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
blockers: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildLaunchMembersFromMeta(metaMembers: TeamMember[]): TeamCreateRequest['members'] {
|
private buildLaunchMembersFromMeta(metaMembers: TeamMember[]): TeamCreateRequest['members'] {
|
||||||
const byName = new Map<string, TeamCreateRequest['members'][number]>();
|
const byName = new Map<string, TeamCreateRequest['members'][number]>();
|
||||||
for (const member of metaMembers) {
|
for (const member of metaMembers) {
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,38 @@ import {
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import {
|
||||||
|
calculateTaskImplementationEventDuration,
|
||||||
|
formatTaskImplementationDuration,
|
||||||
|
} from '@shared/utils/taskWorkDuration';
|
||||||
import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck, UserRound } from 'lucide-react';
|
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 {
|
interface WorkflowTimelineProps {
|
||||||
events: TaskHistoryEvent[];
|
events: TaskHistoryEvent[];
|
||||||
/** Map of member name → color name for colored badges. */
|
/** Map of member name → color name for colored badges. */
|
||||||
memberColorMap?: Map<string, string>;
|
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) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
<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) => {
|
{events.map((event, idx) => {
|
||||||
const isLast = idx === events.length - 1;
|
const isLast = idx === events.length - 1;
|
||||||
const time = formatTime(event.timestamp);
|
const time = formatTime(event.timestamp);
|
||||||
|
const implementationDuration = implementationDurationTask
|
||||||
|
? calculateTaskImplementationEventDuration(
|
||||||
|
implementationDurationTask,
|
||||||
|
event,
|
||||||
|
implementationNowMs
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={event.id} className="flex">
|
<div key={event.id} className="flex">
|
||||||
|
|
@ -47,6 +75,19 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro
|
||||||
{time}
|
{time}
|
||||||
</span>
|
</span>
|
||||||
<EventContent event={event} memberColorMap={memberColorMap} />
|
<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 ? (
|
{shouldShowTrailingActor(event) && event.actor ? (
|
||||||
<span className="ml-auto shrink-0">
|
<span className="ml-auto shrink-0">
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
|
|
@ -78,7 +119,7 @@ const EventContent = ({
|
||||||
}: {
|
}: {
|
||||||
event: TaskHistoryEvent;
|
event: TaskHistoryEvent;
|
||||||
memberColorMap?: Map<string, string>;
|
memberColorMap?: Map<string, string>;
|
||||||
}) => {
|
}): React.JSX.Element => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'task_created':
|
case 'task_created':
|
||||||
return (
|
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 style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending;
|
||||||
const label = TASK_STATUS_LABELS[status] ?? status;
|
const label = TASK_STATUS_LABELS[status] ?? status;
|
||||||
return (
|
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;
|
if (state === 'none') return null;
|
||||||
const display = REVIEW_STATE_DISPLAY[state];
|
const display = REVIEW_STATE_DISPLAY[state];
|
||||||
if (!display) return null;
|
if (!display) return null;
|
||||||
|
|
|
||||||
|
|
@ -1374,7 +1374,7 @@ export const TaskDetailDialog = ({
|
||||||
headerExtra={
|
headerExtra={
|
||||||
showTaskImplementationDuration ? (
|
showTaskImplementationDuration ? (
|
||||||
<span
|
<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"
|
title="Implementation time from persisted work intervals"
|
||||||
>
|
>
|
||||||
<Clock size={10} />
|
<Clock size={10} />
|
||||||
|
|
@ -1384,7 +1384,12 @@ export const TaskDetailDialog = ({
|
||||||
}
|
}
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
>
|
>
|
||||||
<WorkflowTimeline events={currentTask.historyEvents} memberColorMap={colorMap} />
|
<WorkflowTimeline
|
||||||
|
events={currentTask.historyEvents}
|
||||||
|
memberColorMap={colorMap}
|
||||||
|
implementationDurationTask={currentTask}
|
||||||
|
nowMs={taskDurationNowMs}
|
||||||
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,15 @@ interface TaskWorkDurationIntervalLike {
|
||||||
completedAt?: string | null;
|
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<
|
export interface TaskWorkDurationLike<
|
||||||
TInterval extends TaskWorkDurationIntervalLike = TaskWorkDurationIntervalLike,
|
TInterval extends TaskWorkDurationIntervalLike = TaskWorkDurationIntervalLike,
|
||||||
> {
|
> {
|
||||||
|
|
@ -16,12 +25,23 @@ export interface TaskImplementationDuration {
|
||||||
countedIntervalCount: number;
|
countedIntervalCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskImplementationEventDuration {
|
||||||
|
elapsedMs: number;
|
||||||
|
running: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMELINE_EVENT_MATCH_TOLERANCE_MS = 5_000;
|
||||||
|
|
||||||
function parseIsoMs(value: string | null | undefined): number {
|
function parseIsoMs(value: string | null | undefined): number {
|
||||||
if (!value) return 0;
|
if (!value) return 0;
|
||||||
const parsed = Date.parse(value);
|
const parsed = Date.parse(value);
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
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>(
|
export function calculateTaskImplementationDuration<TInterval extends TaskWorkDurationIntervalLike>(
|
||||||
task: TaskWorkDurationLike<TInterval> | null | undefined,
|
task: TaskWorkDurationLike<TInterval> | null | undefined,
|
||||||
nowMs = Date.now()
|
nowMs = Date.now()
|
||||||
|
|
@ -69,6 +89,55 @@ export function calculateTaskImplementationDuration<TInterval extends TaskWorkDu
|
||||||
return { elapsedMs, hasRunningInterval, countedIntervalCount: windows.length };
|
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(
|
export function shouldShowTaskImplementationDuration(
|
||||||
duration: TaskImplementationDuration
|
duration: TaskImplementationDuration
|
||||||
): boolean {
|
): 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(
|
const svc = new TeamProvisioningService(
|
||||||
{} as never,
|
{} as never,
|
||||||
{ listInboxNames: vi.fn(async () => ['tom']) } as never,
|
{ listInboxNames: vi.fn(async () => ['tom']) } as never,
|
||||||
|
|
@ -163,7 +163,7 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
||||||
|
|
||||||
const configRaw = JSON.stringify({
|
const configRaw = JSON.stringify({
|
||||||
name: 't',
|
name: 't',
|
||||||
members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }],
|
members: [{ name: 'tom', role: 'developer', provider: 'opencode' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
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 () => {
|
it('rejects mixed OpenCode config fallback when the side lane is missing an explicit model', async () => {
|
||||||
const svc = new TeamProvisioningService(
|
const svc = new TeamProvisioningService(
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,9 @@ vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({
|
||||||
? React.createElement('span', { 'data-testid': `section-extra-${title}` }, headerExtra)
|
? React.createElement('span', { 'data-testid': `section-extra-${title}` }, headerExtra)
|
||||||
: null
|
: 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.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-04-20T10:07:30.000Z'));
|
vi.setSystemTime(new Date('2026-04-20T10:07:30.000Z'));
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
|
@ -578,9 +580,33 @@ describe('TaskDetailDialog changes summary loading', () => {
|
||||||
historyEvents: [
|
historyEvents: [
|
||||||
{
|
{
|
||||||
id: 'event-created',
|
id: 'event-created',
|
||||||
timestamp: '2026-04-20T10:00:00.000Z',
|
timestamp: '2026-04-20T09:59:00.000Z',
|
||||||
type: 'task_created',
|
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',
|
actor: 'lead',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -607,6 +633,21 @@ describe('TaskDetailDialog changes summary loading', () => {
|
||||||
expect(host.textContent).toContain('Workflow History');
|
expect(host.textContent).toContain('Workflow History');
|
||||||
expect(host.textContent).toContain('Work time 5m 00s');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
calculateTaskImplementationEventDuration,
|
||||||
calculateTaskImplementationDuration,
|
calculateTaskImplementationDuration,
|
||||||
formatTaskImplementationDuration,
|
formatTaskImplementationDuration,
|
||||||
shouldShowTaskImplementationDuration,
|
shouldShowTaskImplementationDuration,
|
||||||
|
|
@ -74,6 +75,86 @@ describe('taskWorkDuration', () => {
|
||||||
expect(duration.countedIntervalCount).toBe(2);
|
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', () => {
|
it('formats seconds, minutes, and hours for compact UI labels', () => {
|
||||||
expect(formatTaskImplementationDuration(42_900)).toBe('42s');
|
expect(formatTaskImplementationDuration(42_900)).toBe('42s');
|
||||||
expect(formatTaskImplementationDuration(65_000)).toBe('1m 05s');
|
expect(formatTaskImplementationDuration(65_000)).toBe('1m 05s');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue