754 lines
24 KiB
TypeScript
754 lines
24 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { api } from '@renderer/api';
|
|
import { useStore } from '@renderer/store';
|
|
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
|
|
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
|
import { getTaskChangeStateBucket } from '@shared/utils/taskChangeState';
|
|
|
|
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
|
|
import {
|
|
buildTeamChangeRequestPlan,
|
|
buildTeamChangesTasksFingerprint,
|
|
TEAM_CHANGES_MAX_REQUESTS,
|
|
} from './teamChangesRequestPlan';
|
|
|
|
import type {
|
|
TaskChangePresenceState,
|
|
TaskChangeSetV2,
|
|
TeamTaskChangeSummaryItem,
|
|
TeamTaskWithKanban,
|
|
} from '@shared/types';
|
|
|
|
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
|
|
const TEAM_CHANGES_COUNTER_AUTO_REFRESH_MS = 60_000;
|
|
const TEAM_CHANGES_FIRST_PAINT_REQUESTS = 3;
|
|
const TEAM_CHANGES_SECOND_PAINT_REQUESTS = 9;
|
|
const TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT = 3;
|
|
const TEAM_CHANGES_SECOND_UNKNOWN_SCAN_LIMIT = 6;
|
|
const TEAM_CHANGES_INITIAL_STAGED_REFRESH_PLAN = [
|
|
TEAM_CHANGES_SECOND_PAINT_REQUESTS,
|
|
TEAM_CHANGES_MAX_REQUESTS,
|
|
] as const;
|
|
const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000;
|
|
|
|
export interface TeamChangeSummaryState {
|
|
taskId: string;
|
|
changeSet: TaskChangeSetV2 | null;
|
|
error?: string;
|
|
}
|
|
|
|
export interface TeamChangeStats {
|
|
eligibleCount: number;
|
|
requestedCount: number;
|
|
deferredCount: number;
|
|
}
|
|
|
|
interface TeamChangesLoadOptions {
|
|
forceFresh?: boolean;
|
|
showSpinner?: boolean;
|
|
preserveOnError?: boolean;
|
|
storeSummaries?: boolean;
|
|
reportError?: boolean;
|
|
blockAutoRetryOnError?: boolean;
|
|
maxRequests?: number;
|
|
unknownScanLimit?: number;
|
|
queueDeferredRefresh?: boolean;
|
|
satisfiedTaskIds?: ReadonlySet<string>;
|
|
stagedRefreshPlan?: readonly number[];
|
|
}
|
|
|
|
interface UseTeamChangesSummariesInput {
|
|
teamName: string;
|
|
tasks: TeamTaskWithKanban[];
|
|
sectionOpen: boolean;
|
|
}
|
|
|
|
interface UseTeamChangesSummariesResult {
|
|
summariesByTaskId: Record<string, TeamChangeSummaryState>;
|
|
badgeCount: number | null;
|
|
stats: TeamChangeStats;
|
|
loading: boolean;
|
|
refreshing: boolean;
|
|
error: string | null;
|
|
refresh: () => void;
|
|
}
|
|
|
|
function normalizeTeamChangeSummaryItem(item: unknown): TeamTaskChangeSummaryItem | null {
|
|
if (!item || typeof item !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const candidate = item as Partial<TeamTaskChangeSummaryItem>;
|
|
const taskId = typeof candidate.taskId === 'string' ? candidate.taskId.trim() : '';
|
|
if (!taskId) {
|
|
return null;
|
|
}
|
|
|
|
const changeSet =
|
|
candidate.changeSet &&
|
|
typeof candidate.changeSet === 'object' &&
|
|
!Array.isArray(candidate.changeSet)
|
|
? candidate.changeSet
|
|
: null;
|
|
const error = typeof candidate.error === 'string' ? candidate.error : undefined;
|
|
return {
|
|
taskId,
|
|
changeSet,
|
|
...(error ? { error } : {}),
|
|
};
|
|
}
|
|
|
|
function getSafeResponseItems(response: unknown): TeamTaskChangeSummaryItem[] {
|
|
if (
|
|
!response ||
|
|
typeof response !== 'object' ||
|
|
!Array.isArray((response as { items?: unknown }).items)
|
|
) {
|
|
throw new Error('Team changes response was malformed.');
|
|
}
|
|
return (response as { items: unknown[] }).items
|
|
.map(normalizeTeamChangeSummaryItem)
|
|
.filter((item): item is TeamTaskChangeSummaryItem => item !== null);
|
|
}
|
|
|
|
function hasSafeFileSummaries(changeSet: TaskChangeSetV2): boolean {
|
|
return changeSet.files.every(
|
|
(file) =>
|
|
file &&
|
|
typeof file === 'object' &&
|
|
typeof file.filePath === 'string' &&
|
|
file.filePath.trim().length > 0
|
|
);
|
|
}
|
|
|
|
function countDisplayableFileSummaries(changeSet: TaskChangeSetV2): number {
|
|
if (!Array.isArray(changeSet.files)) {
|
|
return 0;
|
|
}
|
|
return changeSet.files.filter(
|
|
(file) =>
|
|
file &&
|
|
typeof file === 'object' &&
|
|
typeof file.filePath === 'string' &&
|
|
file.filePath.trim().length > 0
|
|
).length;
|
|
}
|
|
|
|
function isMinimalPresenceChangeSet(changeSet: TaskChangeSetV2): boolean {
|
|
return Boolean(
|
|
Array.isArray(changeSet.files) &&
|
|
hasSafeFileSummaries(changeSet) &&
|
|
Array.isArray(changeSet.warnings) &&
|
|
Number.isFinite(changeSet.totalFiles) &&
|
|
Number(changeSet.totalFiles) >= 0 &&
|
|
typeof changeSet.computedAt === 'string' &&
|
|
changeSet.computedAt.trim().length > 0 &&
|
|
changeSet.scope &&
|
|
typeof changeSet.scope === 'object' &&
|
|
!Array.isArray(changeSet.scope)
|
|
);
|
|
}
|
|
|
|
function resolveCacheablePresenceFromChangeSet(
|
|
changeSet: TaskChangeSetV2
|
|
): Exclude<TaskChangePresenceState, 'unknown'> | null {
|
|
if (!isMinimalPresenceChangeSet(changeSet)) {
|
|
return null;
|
|
}
|
|
|
|
const nextPresence = resolveTaskChangePresenceFromResult(changeSet);
|
|
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
|
|
return nextPresence;
|
|
}
|
|
if (
|
|
nextPresence === 'no_changes' &&
|
|
(changeSet.confidence === 'high' || changeSet.confidence === 'medium')
|
|
) {
|
|
return nextPresence;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function shouldClearSelectedTaskChangePresence(
|
|
task: TeamTaskWithKanban,
|
|
changeSet: TaskChangeSetV2
|
|
): boolean {
|
|
if (!Array.isArray(changeSet.files) || !Array.isArray(changeSet.warnings)) {
|
|
return false;
|
|
}
|
|
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
|
|
if (reviewability === 'diagnostic_only') {
|
|
return true;
|
|
}
|
|
if (reviewability !== 'unknown') {
|
|
return false;
|
|
}
|
|
if (changeSet.files.length > 0 || changeSet.warnings.length > 0) {
|
|
return false;
|
|
}
|
|
return (
|
|
getTaskChangeStateBucket({
|
|
status: task.status,
|
|
reviewState: task.reviewState,
|
|
historyEvents: task.historyEvents,
|
|
kanbanColumn: task.kanbanColumn,
|
|
deletedAt: task.deletedAt,
|
|
}) === 'active'
|
|
);
|
|
}
|
|
|
|
function getTeamChangeBadgeContribution(item: TeamTaskChangeSummaryItem): number {
|
|
if (item.error) {
|
|
return 1;
|
|
}
|
|
|
|
const changeSet = item.changeSet;
|
|
if (!changeSet) {
|
|
return 0;
|
|
}
|
|
|
|
const totalFiles = Number(changeSet.totalFiles);
|
|
if (Number.isFinite(totalFiles) && totalFiles > 0) {
|
|
return Math.trunc(totalFiles);
|
|
}
|
|
|
|
const displayableFileCount = countDisplayableFileSummaries(changeSet);
|
|
if (displayableFileCount > 0) {
|
|
return displayableFileCount;
|
|
}
|
|
|
|
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
|
|
return reviewability === 'attention_required' || reviewability === 'diagnostic_only' ? 1 : 0;
|
|
}
|
|
|
|
function sumTeamChangeBadgeContributions(changeBadgeCountByTaskId: Record<string, number>): number {
|
|
return Object.values(changeBadgeCountByTaskId).reduce((sum, value) => {
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
return sum;
|
|
}
|
|
return sum + Math.trunc(value);
|
|
}, 0);
|
|
}
|
|
|
|
function isDocumentHidden(): boolean {
|
|
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
|
}
|
|
|
|
function isSilentCounterLoad(options: TeamChangesLoadOptions | null): boolean {
|
|
return Boolean(
|
|
options?.storeSummaries === false &&
|
|
options.reportError === false &&
|
|
options.showSpinner !== true
|
|
);
|
|
}
|
|
|
|
function getUnknownScanLimitForStage(maxRequests: number | undefined): number | undefined {
|
|
if (maxRequests === TEAM_CHANGES_FIRST_PAINT_REQUESTS) {
|
|
return TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT;
|
|
}
|
|
if (maxRequests === TEAM_CHANGES_SECOND_PAINT_REQUESTS) {
|
|
return TEAM_CHANGES_SECOND_UNKNOWN_SCAN_LIMIT;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function mergeSuccessfulTaskIds(
|
|
existingTaskIds: ReadonlySet<string> | undefined,
|
|
responseItems: TeamTaskChangeSummaryItem[],
|
|
requestOptionsByTaskId: ReadonlyMap<string, unknown>
|
|
): ReadonlySet<string> | undefined {
|
|
const next = new Set(existingTaskIds);
|
|
for (const item of responseItems) {
|
|
if (item.error || item.changeSet === null || !requestOptionsByTaskId.has(item.taskId)) {
|
|
continue;
|
|
}
|
|
next.add(item.taskId);
|
|
}
|
|
return next.size > 0 ? next : undefined;
|
|
}
|
|
|
|
export function useTeamChangesSummaries({
|
|
teamName,
|
|
tasks,
|
|
sectionOpen,
|
|
}: UseTeamChangesSummariesInput): UseTeamChangesSummariesResult {
|
|
const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence);
|
|
const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence);
|
|
const [summariesByTaskId, setSummariesByTaskId] = useState<
|
|
Record<string, TeamChangeSummaryState>
|
|
>({});
|
|
const [changeBadgeCountByTaskId, setChangeBadgeCountByTaskId] = useState<Record<string, number>>(
|
|
{}
|
|
);
|
|
const [counterLoaded, setCounterLoaded] = useState(false);
|
|
const [stats, setStats] = useState<TeamChangeStats>({
|
|
eligibleCount: 0,
|
|
requestedCount: 0,
|
|
deferredCount: 0,
|
|
});
|
|
const [loading, setLoading] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [queuedRefreshTick, setQueuedRefreshTick] = useState(0);
|
|
const hasLoadedRef = useRef(false);
|
|
const mountedRef = useRef(true);
|
|
const requestSeqRef = useRef(0);
|
|
const activeRequestSeqRef = useRef<number | null>(null);
|
|
const activeRequestOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
|
|
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
|
|
const autoRefreshBlockedUntilRef = useRef(0);
|
|
const unknownScanCursorRef = useRef(0);
|
|
const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
|
|
const lastCounterTasksFingerprintRef = useRef<string | null>(null);
|
|
const tasksFingerprint = useMemo(() => buildTeamChangesTasksFingerprint(tasks), [tasks]);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
requestSeqRef.current += 1;
|
|
activeRequestSeqRef.current = null;
|
|
activeRequestOptionsRef.current = null;
|
|
queuedRefreshOptionsRef.current = null;
|
|
autoRefreshBlockedUntilRef.current = 0;
|
|
hasLoadedRef.current = false;
|
|
unknownScanCursorRef.current = 0;
|
|
lastRequestedTasksFingerprintRef.current = null;
|
|
lastCounterTasksFingerprintRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
const loadSummaries = useCallback(
|
|
async ({
|
|
forceFresh = false,
|
|
showSpinner = false,
|
|
preserveOnError = true,
|
|
storeSummaries = true,
|
|
reportError = true,
|
|
blockAutoRetryOnError = true,
|
|
maxRequests,
|
|
unknownScanLimit,
|
|
queueDeferredRefresh = false,
|
|
satisfiedTaskIds,
|
|
stagedRefreshPlan,
|
|
}: TeamChangesLoadOptions = {}): Promise<void> => {
|
|
if (forceFresh) {
|
|
autoRefreshBlockedUntilRef.current = 0;
|
|
} else if (autoRefreshBlockedUntilRef.current > Date.now()) {
|
|
return;
|
|
}
|
|
|
|
const shouldPreemptSilentCounterLoad =
|
|
activeRequestSeqRef.current !== null &&
|
|
storeSummaries &&
|
|
isSilentCounterLoad(activeRequestOptionsRef.current);
|
|
if (shouldPreemptSilentCounterLoad) {
|
|
requestSeqRef.current += 1;
|
|
activeRequestSeqRef.current = null;
|
|
activeRequestOptionsRef.current = null;
|
|
queuedRefreshOptionsRef.current = null;
|
|
}
|
|
|
|
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
|
|
const previous = queuedRefreshOptionsRef.current;
|
|
queuedRefreshOptionsRef.current = {
|
|
forceFresh: Boolean(previous?.forceFresh || forceFresh),
|
|
showSpinner: Boolean(previous?.showSpinner || showSpinner),
|
|
preserveOnError: previous
|
|
? Boolean(previous.preserveOnError && preserveOnError)
|
|
: preserveOnError,
|
|
storeSummaries: Boolean(previous?.storeSummaries || storeSummaries),
|
|
reportError: previous ? Boolean(previous.reportError || reportError) : reportError,
|
|
blockAutoRetryOnError: previous
|
|
? Boolean(previous.blockAutoRetryOnError || blockAutoRetryOnError)
|
|
: blockAutoRetryOnError,
|
|
maxRequests:
|
|
maxRequests === undefined
|
|
? undefined
|
|
: previous?.maxRequests === undefined
|
|
? maxRequests
|
|
: Math.max(previous.maxRequests, maxRequests),
|
|
unknownScanLimit:
|
|
unknownScanLimit === undefined
|
|
? undefined
|
|
: previous?.unknownScanLimit === undefined
|
|
? unknownScanLimit
|
|
: Math.max(previous.unknownScanLimit, unknownScanLimit),
|
|
queueDeferredRefresh: Boolean(previous?.queueDeferredRefresh || queueDeferredRefresh),
|
|
satisfiedTaskIds:
|
|
previous?.satisfiedTaskIds && satisfiedTaskIds
|
|
? new Set(
|
|
[...previous.satisfiedTaskIds].filter((taskId) => satisfiedTaskIds.has(taskId))
|
|
)
|
|
: undefined,
|
|
stagedRefreshPlan:
|
|
stagedRefreshPlan !== undefined
|
|
? stagedRefreshPlan
|
|
: maxRequests === undefined && unknownScanLimit === undefined
|
|
? undefined
|
|
: previous?.stagedRefreshPlan,
|
|
};
|
|
if (showSpinner) {
|
|
setLoading(true);
|
|
} else if (storeSummaries) {
|
|
setRefreshing(true);
|
|
}
|
|
if (activeRequestSeqRef.current === null) {
|
|
setQueuedRefreshTick((value) => value + 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh, {
|
|
maxRequests,
|
|
unknownScanLimit,
|
|
satisfiedTaskIds,
|
|
});
|
|
unknownScanCursorRef.current = plan.nextUnknownScanCursor;
|
|
const requestSeq = requestSeqRef.current + 1;
|
|
requestSeqRef.current = requestSeq;
|
|
setStats({
|
|
eligibleCount: plan.eligibleCount,
|
|
requestedCount: plan.requestedCount,
|
|
deferredCount: plan.deferredCount,
|
|
});
|
|
setError(null);
|
|
|
|
if (plan.requests.length === 0) {
|
|
if (storeSummaries) {
|
|
setSummariesByTaskId({});
|
|
}
|
|
setChangeBadgeCountByTaskId({});
|
|
setCounterLoaded(true);
|
|
autoRefreshBlockedUntilRef.current = 0;
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
return;
|
|
}
|
|
|
|
if (showSpinner) {
|
|
setLoading(true);
|
|
} else if (storeSummaries) {
|
|
setRefreshing(true);
|
|
}
|
|
activeRequestSeqRef.current = requestSeq;
|
|
activeRequestOptionsRef.current = {
|
|
forceFresh,
|
|
showSpinner,
|
|
preserveOnError,
|
|
storeSummaries,
|
|
reportError,
|
|
blockAutoRetryOnError,
|
|
maxRequests,
|
|
unknownScanLimit,
|
|
queueDeferredRefresh,
|
|
satisfiedTaskIds,
|
|
stagedRefreshPlan,
|
|
};
|
|
|
|
try {
|
|
const response = await withTeamChangesLoadTimeout(
|
|
api.review.getTeamTaskChangeSummaries(teamName, plan.requests)
|
|
);
|
|
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
|
return;
|
|
}
|
|
if (queuedRefreshOptionsRef.current !== null) {
|
|
return;
|
|
}
|
|
autoRefreshBlockedUntilRef.current = 0;
|
|
const responseItems = getSafeResponseItems(response);
|
|
|
|
const currentTaskIds = new Set(tasks.map((task) => task.id));
|
|
const taskById = new Map<string, TeamTaskWithKanban>();
|
|
for (const task of tasks) {
|
|
if (!taskById.has(task.id)) {
|
|
taskById.set(task.id, task);
|
|
}
|
|
}
|
|
|
|
setChangeBadgeCountByTaskId((previous) => {
|
|
const next: Record<string, number> = {};
|
|
for (const [taskId, badgeContribution] of Object.entries(previous)) {
|
|
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
|
|
next[taskId] = badgeContribution;
|
|
}
|
|
}
|
|
for (const item of responseItems) {
|
|
if (!plan.requestOptionsByTaskId.has(item.taskId)) continue;
|
|
const nextContribution = getTeamChangeBadgeContribution(item);
|
|
const previousContribution = previous[item.taskId];
|
|
next[item.taskId] =
|
|
item.error &&
|
|
Number.isFinite(previousContribution) &&
|
|
previousContribution > nextContribution
|
|
? previousContribution
|
|
: nextContribution;
|
|
}
|
|
return next;
|
|
});
|
|
setCounterLoaded(true);
|
|
|
|
for (const item of responseItems) {
|
|
const changeSet = item.changeSet;
|
|
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
|
if (!changeSet || !options) continue;
|
|
|
|
const nextPresence = resolveCacheablePresenceFromChangeSet(changeSet);
|
|
if (!nextPresence) {
|
|
const task = taskById.get(item.taskId);
|
|
if (
|
|
task?.changePresence &&
|
|
task.changePresence !== 'unknown' &&
|
|
shouldClearSelectedTaskChangePresence(task, changeSet)
|
|
) {
|
|
setSelectedTeamTaskChangePresence(teamName, item.taskId, 'unknown');
|
|
}
|
|
continue;
|
|
}
|
|
recordTaskChangePresence(teamName, item.taskId, options, nextPresence);
|
|
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
|
|
}
|
|
|
|
if (storeSummaries) {
|
|
setSummariesByTaskId((previous) => {
|
|
const next: Record<string, TeamChangeSummaryState> = {};
|
|
for (const [taskId, summary] of Object.entries(previous)) {
|
|
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
|
|
next[taskId] = summary;
|
|
}
|
|
}
|
|
for (const item of responseItems) {
|
|
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
|
if (!options) continue;
|
|
next[item.taskId] = {
|
|
taskId: item.taskId,
|
|
changeSet: item.changeSet,
|
|
error: item.error,
|
|
};
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
if (storeSummaries && queueDeferredRefresh && plan.deferredCount > 0) {
|
|
const [nextStageMaxRequests, ...remainingStages] = stagedRefreshPlan ?? [];
|
|
const successfulTaskIds = mergeSuccessfulTaskIds(
|
|
satisfiedTaskIds,
|
|
responseItems,
|
|
plan.requestOptionsByTaskId
|
|
);
|
|
queuedRefreshOptionsRef.current = {
|
|
forceFresh,
|
|
showSpinner: false,
|
|
preserveOnError: true,
|
|
storeSummaries: true,
|
|
reportError: true,
|
|
blockAutoRetryOnError: true,
|
|
maxRequests: nextStageMaxRequests,
|
|
unknownScanLimit: getUnknownScanLimitForStage(nextStageMaxRequests),
|
|
queueDeferredRefresh: remainingStages.length > 0,
|
|
stagedRefreshPlan: remainingStages.length > 0 ? remainingStages : undefined,
|
|
satisfiedTaskIds: successfulTaskIds,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
|
return;
|
|
}
|
|
const queuedOptions = queuedRefreshOptionsRef.current;
|
|
const shouldRunVisibleQueuedRefreshAfterSilentFailure =
|
|
!storeSummaries &&
|
|
!reportError &&
|
|
Boolean(queuedOptions?.showSpinner || queuedOptions?.storeSummaries);
|
|
if (!shouldRunVisibleQueuedRefreshAfterSilentFailure) {
|
|
queuedRefreshOptionsRef.current = null;
|
|
}
|
|
if (blockAutoRetryOnError) {
|
|
autoRefreshBlockedUntilRef.current =
|
|
Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS;
|
|
}
|
|
if (!preserveOnError) {
|
|
setSummariesByTaskId({});
|
|
}
|
|
if (reportError) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load team changes');
|
|
}
|
|
} finally {
|
|
if (mountedRef.current) {
|
|
const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null;
|
|
if (activeRequestSeqRef.current === requestSeq) {
|
|
activeRequestSeqRef.current = null;
|
|
activeRequestOptionsRef.current = null;
|
|
}
|
|
if (hasQueuedRefresh && activeRequestSeqRef.current === null) {
|
|
setQueuedRefreshTick((value) => value + 1);
|
|
}
|
|
const shouldStopIndicators =
|
|
requestSeqRef.current === requestSeq ||
|
|
(!hasQueuedRefresh && activeRequestSeqRef.current === null);
|
|
if (shouldStopIndicators) {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[recordTaskChangePresence, setSelectedTeamTaskChangePresence, tasks, teamName]
|
|
);
|
|
|
|
useEffect(() => {
|
|
hasLoadedRef.current = false;
|
|
requestSeqRef.current += 1;
|
|
activeRequestSeqRef.current = null;
|
|
activeRequestOptionsRef.current = null;
|
|
queuedRefreshOptionsRef.current = null;
|
|
autoRefreshBlockedUntilRef.current = 0;
|
|
unknownScanCursorRef.current = 0;
|
|
lastRequestedTasksFingerprintRef.current = null;
|
|
lastCounterTasksFingerprintRef.current = null;
|
|
setSummariesByTaskId({});
|
|
setChangeBadgeCountByTaskId({});
|
|
setCounterLoaded(false);
|
|
setError(null);
|
|
setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 });
|
|
}, [teamName]);
|
|
|
|
useEffect(() => {
|
|
if (!sectionOpen) {
|
|
requestSeqRef.current += 1;
|
|
activeRequestSeqRef.current = null;
|
|
activeRequestOptionsRef.current = null;
|
|
queuedRefreshOptionsRef.current = null;
|
|
autoRefreshBlockedUntilRef.current = 0;
|
|
hasLoadedRef.current = false;
|
|
lastRequestedTasksFingerprintRef.current = null;
|
|
setSummariesByTaskId({});
|
|
setError(null);
|
|
setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 });
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, [sectionOpen]);
|
|
|
|
useEffect(() => {
|
|
if (sectionOpen) {
|
|
return;
|
|
}
|
|
if (lastCounterTasksFingerprintRef.current === tasksFingerprint && counterLoaded) {
|
|
return;
|
|
}
|
|
lastCounterTasksFingerprintRef.current = tasksFingerprint;
|
|
void loadSummaries({
|
|
showSpinner: false,
|
|
preserveOnError: true,
|
|
storeSummaries: false,
|
|
reportError: false,
|
|
blockAutoRetryOnError: false,
|
|
});
|
|
}, [counterLoaded, loadSummaries, sectionOpen, tasksFingerprint]);
|
|
|
|
useEffect(() => {
|
|
if (!sectionOpen || hasLoadedRef.current) {
|
|
return;
|
|
}
|
|
hasLoadedRef.current = true;
|
|
lastRequestedTasksFingerprintRef.current = tasksFingerprint;
|
|
void loadSummaries({
|
|
showSpinner: true,
|
|
preserveOnError: false,
|
|
maxRequests: TEAM_CHANGES_FIRST_PAINT_REQUESTS,
|
|
unknownScanLimit: TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT,
|
|
queueDeferredRefresh: true,
|
|
stagedRefreshPlan: TEAM_CHANGES_INITIAL_STAGED_REFRESH_PLAN,
|
|
});
|
|
}, [loadSummaries, sectionOpen, tasksFingerprint]);
|
|
|
|
useEffect(() => {
|
|
if (!sectionOpen || !hasLoadedRef.current) {
|
|
return;
|
|
}
|
|
if (lastRequestedTasksFingerprintRef.current === tasksFingerprint) {
|
|
return;
|
|
}
|
|
lastRequestedTasksFingerprintRef.current = tasksFingerprint;
|
|
void loadSummaries({ showSpinner: false, preserveOnError: true });
|
|
}, [loadSummaries, sectionOpen, tasksFingerprint]);
|
|
|
|
useEffect(() => {
|
|
if (activeRequestSeqRef.current !== null) {
|
|
return;
|
|
}
|
|
const options = queuedRefreshOptionsRef.current;
|
|
if (!options) {
|
|
return;
|
|
}
|
|
queuedRefreshOptionsRef.current = null;
|
|
void loadSummaries(options);
|
|
}, [loadSummaries, queuedRefreshTick]);
|
|
|
|
useEffect(() => {
|
|
if (!sectionOpen) {
|
|
return;
|
|
}
|
|
|
|
const timer = window.setInterval(() => {
|
|
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
|
|
return;
|
|
}
|
|
void loadSummaries({ showSpinner: false, preserveOnError: true });
|
|
}, TEAM_CHANGES_AUTO_REFRESH_MS);
|
|
|
|
return () => {
|
|
window.clearInterval(timer);
|
|
};
|
|
}, [loadSummaries, sectionOpen]);
|
|
|
|
useEffect(() => {
|
|
if (sectionOpen) {
|
|
return;
|
|
}
|
|
|
|
const timer = window.setInterval(() => {
|
|
if (isDocumentHidden()) {
|
|
return;
|
|
}
|
|
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
|
|
return;
|
|
}
|
|
void loadSummaries({
|
|
showSpinner: false,
|
|
preserveOnError: true,
|
|
storeSummaries: false,
|
|
reportError: false,
|
|
blockAutoRetryOnError: false,
|
|
});
|
|
}, TEAM_CHANGES_COUNTER_AUTO_REFRESH_MS);
|
|
|
|
return () => {
|
|
window.clearInterval(timer);
|
|
};
|
|
}, [loadSummaries, sectionOpen]);
|
|
|
|
const refresh = useCallback(() => {
|
|
void loadSummaries({
|
|
forceFresh: true,
|
|
showSpinner: true,
|
|
preserveOnError: false,
|
|
maxRequests: TEAM_CHANGES_FIRST_PAINT_REQUESTS,
|
|
unknownScanLimit: TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT,
|
|
queueDeferredRefresh: true,
|
|
stagedRefreshPlan: TEAM_CHANGES_INITIAL_STAGED_REFRESH_PLAN,
|
|
});
|
|
}, [loadSummaries]);
|
|
|
|
return {
|
|
summariesByTaskId,
|
|
badgeCount: counterLoaded ? sumTeamChangeBadgeContributions(changeBadgeCountByTaskId) : null,
|
|
stats,
|
|
loading,
|
|
refreshing,
|
|
error,
|
|
refresh,
|
|
};
|
|
}
|