446 lines
13 KiB
TypeScript
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();
|
|
}
|