154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
import { memo, useEffect, useRef, useState } from 'react';
|
|
|
|
import { useAppTranslation } from '@features/localization/renderer';
|
|
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
|
import {
|
|
formatMemberActivityElapsed,
|
|
readMemberActivityTimerElapsed,
|
|
syncMemberActivityTimer,
|
|
} from '@renderer/utils/memberActivityTimer';
|
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
|
|
|
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
|
import type { TeamTaskWithKanban } from '@shared/types';
|
|
|
|
interface CurrentTaskIndicatorProps {
|
|
task: TeamTaskWithKanban;
|
|
borderColor: string;
|
|
maxSubjectLength?: number;
|
|
activityLabel?: string;
|
|
activityTimer?: MemberActivityTimerAnchor | null;
|
|
isTimerRunning?: boolean;
|
|
onOpenTask?: () => void;
|
|
}
|
|
|
|
const ACTIVITY_TIMER_STORAGE_SYNC_INTERVAL_MS = 5_000;
|
|
|
|
function useActivityTimerLabel(
|
|
activityTimer: MemberActivityTimerAnchor | null | undefined,
|
|
isTimerRunning: boolean
|
|
): string | null {
|
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
const lastStorageSyncAtRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (!activityTimer) return;
|
|
const now = Date.now();
|
|
lastStorageSyncAtRef.current = now;
|
|
syncMemberActivityTimer({
|
|
timerId: activityTimer.timerId,
|
|
startedAtMs: activityTimer.startedAtMs,
|
|
baseElapsedMs: activityTimer.baseElapsedMs,
|
|
running: isTimerRunning,
|
|
runId: activityTimer.runId,
|
|
nowMs: now,
|
|
});
|
|
|
|
return () => {
|
|
syncMemberActivityTimer({
|
|
timerId: activityTimer.timerId,
|
|
startedAtMs: activityTimer.startedAtMs,
|
|
baseElapsedMs: activityTimer.baseElapsedMs,
|
|
running: isTimerRunning,
|
|
runId: activityTimer.runId,
|
|
nowMs: Date.now(),
|
|
});
|
|
};
|
|
}, [activityTimer, isTimerRunning]);
|
|
|
|
useEffect(() => {
|
|
if (!activityTimer || !isTimerRunning) return;
|
|
const handle = window.setInterval(() => {
|
|
const now = Date.now();
|
|
if (now - lastStorageSyncAtRef.current >= ACTIVITY_TIMER_STORAGE_SYNC_INTERVAL_MS) {
|
|
lastStorageSyncAtRef.current = now;
|
|
syncMemberActivityTimer({
|
|
timerId: activityTimer.timerId,
|
|
startedAtMs: activityTimer.startedAtMs,
|
|
baseElapsedMs: activityTimer.baseElapsedMs,
|
|
running: true,
|
|
runId: activityTimer.runId,
|
|
nowMs: now,
|
|
});
|
|
}
|
|
setNowMs(now);
|
|
}, 1000);
|
|
return () => window.clearInterval(handle);
|
|
}, [activityTimer, isTimerRunning]);
|
|
|
|
if (!activityTimer) return null;
|
|
return formatMemberActivityElapsed(
|
|
readMemberActivityTimerElapsed({
|
|
timerId: activityTimer.timerId,
|
|
startedAtMs: activityTimer.startedAtMs,
|
|
baseElapsedMs: activityTimer.baseElapsedMs,
|
|
running: isTimerRunning,
|
|
runId: activityTimer.runId,
|
|
nowMs,
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Inline indicator showing a spinning loader + "working on" + task label button.
|
|
* Shared between MemberCard and MemberHoverCard.
|
|
*/
|
|
export const CurrentTaskIndicator = memo(
|
|
({
|
|
task,
|
|
borderColor,
|
|
maxSubjectLength,
|
|
activityLabel = 'working on',
|
|
activityTimer,
|
|
isTimerRunning = true,
|
|
onOpenTask,
|
|
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
|
const { t } = useAppTranslation('team');
|
|
const timerLabel = useActivityTimerLabel(activityTimer, isTimerRunning);
|
|
const subjectText =
|
|
typeof maxSubjectLength === 'number' &&
|
|
maxSubjectLength > 0 &&
|
|
task.subject.length > maxSubjectLength
|
|
? `${task.subject.slice(0, maxSubjectLength)}…`
|
|
: task.subject;
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
<SyncedLoader2
|
|
className="size-3 shrink-0"
|
|
spinning={isTimerRunning}
|
|
style={{ color: borderColor }}
|
|
/>
|
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
|
<button
|
|
type="button"
|
|
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
|
title={t('tasks.openTask')}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onOpenTask?.();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onOpenTask?.();
|
|
}
|
|
}}
|
|
>
|
|
{formatTaskDisplayLabel(task)} {subjectText}
|
|
</button>
|
|
{timerLabel ? (
|
|
<span
|
|
className="shrink-0 text-[9px] font-medium tabular-nums text-[var(--color-text-muted)]"
|
|
title={`Active for ${timerLabel}`}
|
|
>
|
|
{timerLabel}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
CurrentTaskIndicator.displayName = 'CurrentTaskIndicator';
|