agent-ecosystem/src/renderer/utils/memberActivityTimer.ts

446 lines
13 KiB
TypeScript

import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState';
import type { TeamTaskWithKanban } from '@shared/types';
export type MemberActivityPhase = 'work' | 'review';
export interface MemberActivityTimerAnchor {
timerId: string;
startedAt: string;
startedAtMs: number;
baseElapsedMs: number;
runId?: string | null;
}
interface StoredActivityTimer {
version: 1;
startedAtMs: number;
baseElapsedMs: number;
elapsedMs: number;
updatedAtMs: number;
running: boolean;
runId?: string | null;
}
const STORAGE_PREFIX = 'member-activity-timer:';
const MAX_UNOBSERVED_RUN_TRANSITION_MS = 5_000;
const timers = new Map<string, StoredActivityTimer>();
function parseIsoMs(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizeMemberName(value: string | null | undefined): string {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
}
function safeStorageGet(key: string): string | null {
try {
return globalThis.localStorage?.getItem(key) ?? null;
} catch {
return null;
}
}
function safeStorageSet(key: string, value: string): void {
try {
globalThis.localStorage?.setItem(key, value);
} catch {
// localStorage can be unavailable in tests or restricted browser contexts.
}
}
function storageKey(timerId: string): string {
return `${STORAGE_PREFIX}${timerId}`;
}
function isStoredTimer(value: unknown): value is StoredActivityTimer {
if (!value || typeof value !== 'object') return false;
const row = value as Partial<StoredActivityTimer>;
return (
row.version === 1 &&
typeof row.startedAtMs === 'number' &&
Number.isFinite(row.startedAtMs) &&
(row.baseElapsedMs === undefined ||
(typeof row.baseElapsedMs === 'number' && Number.isFinite(row.baseElapsedMs))) &&
typeof row.elapsedMs === 'number' &&
Number.isFinite(row.elapsedMs) &&
typeof row.updatedAtMs === 'number' &&
Number.isFinite(row.updatedAtMs) &&
typeof row.running === 'boolean' &&
(row.runId === undefined || row.runId === null || typeof row.runId === 'string')
);
}
function readStoredTimer(
timerId: string,
startedAtMs: number,
baseElapsedMs: number
): StoredActivityTimer | null {
const cached = timers.get(timerId);
if (cached?.startedAtMs === startedAtMs) {
return cached.baseElapsedMs === baseElapsedMs
? cached
: { ...cached, baseElapsedMs, elapsedMs: Math.max(baseElapsedMs, cached.elapsedMs) };
}
const raw = safeStorageGet(storageKey(timerId));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!isStoredTimer(parsed) || parsed.startedAtMs !== startedAtMs) return null;
const sanitized: StoredActivityTimer = {
version: 1,
startedAtMs: parsed.startedAtMs,
baseElapsedMs,
elapsedMs: Math.max(baseElapsedMs, parsed.elapsedMs),
updatedAtMs: Math.max(parsed.startedAtMs, parsed.updatedAtMs),
running: parsed.running,
runId: parsed.runId ?? null,
};
timers.set(timerId, sanitized);
return sanitized;
} catch {
return null;
}
}
function writeStoredTimer(timerId: string, timer: StoredActivityTimer): void {
timers.set(timerId, timer);
safeStorageSet(storageKey(timerId), JSON.stringify(timer));
}
function createInitialTimer(
startedAtMs: number,
baseElapsedMs: number,
running: boolean,
nowMs: number,
runId: string | null | undefined
): StoredActivityTimer {
if (running) {
return {
version: 1,
startedAtMs,
baseElapsedMs,
elapsedMs: baseElapsedMs,
updatedAtMs: startedAtMs,
running: true,
runId,
};
}
return {
version: 1,
startedAtMs,
baseElapsedMs,
elapsedMs: baseElapsedMs,
updatedAtMs: nowMs,
running: false,
runId,
};
}
function materializeElapsed(
timer: StoredActivityTimer,
nowMs: number,
runId: string | null | undefined
): number {
const baseElapsedMs = Math.max(0, timer.baseElapsedMs);
if (!timer.running) return Math.max(baseElapsedMs, timer.elapsedMs);
const rawGapMs = Math.max(0, nowMs - timer.updatedAtMs);
const sameRun = (timer.runId ?? null) === (runId ?? null);
const gapMs = sameRun ? rawGapMs : Math.min(rawGapMs, MAX_UNOBSERVED_RUN_TRANSITION_MS);
return Math.max(baseElapsedMs, timer.elapsedMs + gapMs);
}
export function createMemberActivityTimerId({
teamName,
memberName,
phase,
taskId,
startedAt,
}: {
teamName: string;
memberName: string;
phase: MemberActivityPhase;
taskId: string;
startedAt: string;
}): string {
return [teamName, normalizeMemberName(memberName), phase, taskId, startedAt].join('\u0000');
}
export function syncMemberActivityTimer({
timerId,
startedAtMs,
baseElapsedMs = 0,
running,
runId,
nowMs = Date.now(),
}: {
timerId: string;
startedAtMs: number;
baseElapsedMs?: number;
running: boolean;
runId?: string | null;
nowMs?: number;
}): number {
const existing =
readStoredTimer(timerId, startedAtMs, baseElapsedMs) ??
createInitialTimer(startedAtMs, baseElapsedMs, running, nowMs, runId);
const elapsedMs = materializeElapsed(existing, nowMs, runId);
const next: StoredActivityTimer = {
version: 1,
startedAtMs,
baseElapsedMs,
elapsedMs,
updatedAtMs: nowMs,
running,
runId,
};
writeStoredTimer(timerId, next);
return elapsedMs;
}
export function readMemberActivityTimerElapsed({
timerId,
startedAtMs,
baseElapsedMs = 0,
running,
runId,
nowMs = Date.now(),
}: {
timerId: string;
startedAtMs: number;
baseElapsedMs?: number;
running: boolean;
runId?: string | null;
nowMs?: number;
}): number {
const timer =
readStoredTimer(timerId, startedAtMs, baseElapsedMs) ??
createInitialTimer(startedAtMs, baseElapsedMs, running, nowMs, runId);
return materializeElapsed(timer, nowMs, runId);
}
export function formatMemberActivityElapsed(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (totalMinutes < 60) {
return `${totalMinutes}m ${String(seconds).padStart(2, '0')}s`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
}
export function deriveWorkActivityTimerAnchor(
task: TeamTaskWithKanban,
params: {
teamName: string;
memberName: string;
}
): MemberActivityTimerAnchor | null {
if (!isTeamTaskActivelyWorked(task)) return null;
const intervals = Array.isArray(task.workIntervals) ? task.workIntervals : [];
let baseElapsedMs = 0;
for (let index = intervals.length - 1; index >= 0; index -= 1) {
const interval = intervals[index];
const startedAtMs = parseIsoMs(interval?.startedAt);
if (startedAtMs > 0 && interval?.completedAt === undefined) {
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
const previous = intervals[previousIndex];
const previousStartedAtMs = parseIsoMs(previous?.startedAt);
const previousCompletedAtMs = parseIsoMs(previous?.completedAt);
if (previousStartedAtMs > 0 && previousCompletedAtMs > previousStartedAtMs) {
baseElapsedMs += previousCompletedAtMs - previousStartedAtMs;
}
}
return {
startedAt: interval.startedAt,
startedAtMs,
baseElapsedMs,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'work',
taskId: task.id,
startedAt: interval.startedAt,
}),
};
}
}
if (intervals.length > 0) return null;
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === 'status_changed' && event.to === 'in_progress') {
const startedAtMs = parseIsoMs(event.timestamp);
if (startedAtMs > 0) {
return {
startedAt: event.timestamp,
startedAtMs,
baseElapsedMs: 0,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'work',
taskId: task.id,
startedAt: event.timestamp,
}),
};
}
}
if (event.type === 'task_created' && event.status === 'in_progress') {
const startedAtMs = parseIsoMs(event.timestamp);
if (startedAtMs > 0) {
return {
startedAt: event.timestamp,
startedAtMs,
baseElapsedMs: 0,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'work',
taskId: task.id,
startedAt: event.timestamp,
}),
};
}
}
}
return null;
}
export function deriveReviewActivityTimerAnchor(
task: TeamTaskWithKanban,
params: {
teamName: string;
memberName: string;
}
): MemberActivityTimerAnchor | null {
const memberKey = normalizeMemberName(params.memberName);
if (!memberKey) return null;
const reviewIntervals = Array.isArray(task.reviewIntervals) ? task.reviewIntervals : [];
for (let index = reviewIntervals.length - 1; index >= 0; index -= 1) {
const interval = reviewIntervals[index];
if (
normalizeMemberName(interval?.reviewer) !== memberKey ||
interval?.completedAt !== undefined
) {
continue;
}
const startedAtMs = parseIsoMs(interval.startedAt);
if (startedAtMs <= 0) return null;
const cycleStartedAtMs = getCurrentReviewCycleStartedAtMs(task, startedAtMs);
let baseElapsedMs = 0;
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
const previous = reviewIntervals[previousIndex];
if (normalizeMemberName(previous?.reviewer) !== memberKey) continue;
const previousStartedAtMs = parseIsoMs(previous?.startedAt);
const previousCompletedAtMs = parseIsoMs(previous?.completedAt);
if (
previousStartedAtMs >= cycleStartedAtMs &&
previousStartedAtMs > 0 &&
previousCompletedAtMs > previousStartedAtMs
) {
baseElapsedMs += previousCompletedAtMs - previousStartedAtMs;
}
}
return {
startedAt: interval.startedAt,
startedAtMs,
baseElapsedMs,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'review',
taskId: task.id,
startedAt: interval.startedAt,
}),
};
}
const anchorEvent = getCurrentReviewTimerAnchorEvent(task, memberKey);
if (!anchorEvent) return null;
const startedAtMs = parseIsoMs(anchorEvent.timestamp);
if (startedAtMs <= 0) return null;
return {
startedAt: anchorEvent.timestamp,
startedAtMs,
baseElapsedMs: 0,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'review',
taskId: task.id,
startedAt: anchorEvent.timestamp,
}),
};
}
function getCurrentReviewTimerAnchorEvent(
task: TeamTaskWithKanban,
memberKey: string
): { timestamp: string } | null {
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === 'review_started') {
return normalizeMemberName(event.actor) === memberKey ? { timestamp: event.timestamp } : null;
}
if (
event.type === 'review_requested' ||
event.type === 'review_approved' ||
event.type === 'review_changes_requested' ||
event.type === 'task_created' ||
(event.type === 'status_changed' &&
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted'))
) {
return null;
}
}
return null;
}
function getCurrentReviewCycleStartedAtMs(task: TeamTaskWithKanban, fallbackMs: number): number {
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === 'review_started') {
const startedAtMs = parseIsoMs(event.timestamp);
return startedAtMs > 0 ? startedAtMs : fallbackMs;
}
if (
event.type === 'review_approved' ||
event.type === 'review_changes_requested' ||
event.type === 'task_created' ||
(event.type === 'status_changed' &&
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted'))
) {
return fallbackMs;
}
}
return fallbackMs;
}
export function resetMemberActivityTimerStoreForTests(): void {
timers.clear();
}