136 lines
5.0 KiB
TypeScript
136 lines
5.0 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useQueries } from "@tanstack/react-query";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { ApiError } from "@/api/client";
|
|
import { issuesApi } from "@/api/issues";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import {
|
|
RECENT_TASKS_UPDATED_EVENT,
|
|
getRecentTasksStorageKey,
|
|
pruneRecentTasks,
|
|
readRecentTasks,
|
|
updateRecentTaskSnapshots,
|
|
type RecentTaskEntry,
|
|
} from "@/lib/recent-tasks";
|
|
|
|
type RecentTasksUpdatedDetail = {
|
|
storageKey: string;
|
|
entries: RecentTaskEntry[];
|
|
};
|
|
|
|
const RECENT_TASK_ORDER_DEBOUNCE_MS = 1_000;
|
|
|
|
export function useRecentTasks({
|
|
companyId,
|
|
userId,
|
|
}: {
|
|
companyId: string | null | undefined;
|
|
userId: string | null | undefined;
|
|
}) {
|
|
const storageKey = useMemo(
|
|
() => companyId ? getRecentTasksStorageKey(companyId, userId) : null,
|
|
[companyId, userId],
|
|
);
|
|
const [entries, setEntries] = useState<RecentTaskEntry[]>(() => (
|
|
storageKey && companyId ? readRecentTasks(storageKey, companyId) : []
|
|
));
|
|
|
|
useEffect(() => {
|
|
setEntries(storageKey && companyId ? readRecentTasks(storageKey, companyId) : []);
|
|
}, [companyId, storageKey]);
|
|
|
|
useEffect(() => {
|
|
if (!storageKey || !companyId) return;
|
|
|
|
const sync = () => setEntries(readRecentTasks(storageKey, companyId));
|
|
const onStorage = (event: StorageEvent) => {
|
|
if (event.key === storageKey) sync();
|
|
};
|
|
const onRecentTasksUpdated = (event: Event) => {
|
|
const detail = (event as CustomEvent<RecentTasksUpdatedDetail>).detail;
|
|
if (detail?.storageKey === storageKey) setEntries(detail.entries);
|
|
};
|
|
|
|
window.addEventListener("storage", onStorage);
|
|
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, onRecentTasksUpdated);
|
|
return () => {
|
|
window.removeEventListener("storage", onStorage);
|
|
window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, onRecentTasksUpdated);
|
|
};
|
|
}, [companyId, storageKey]);
|
|
|
|
// Keep query observers in a fixed order when activity changes the display order.
|
|
const queryEntries = [...entries].sort((left, right) => left.id.localeCompare(right.id));
|
|
const detailQueries = useQueries({
|
|
queries: queryEntries.map((entry) => ({
|
|
queryKey: queryKeys.issues.detail(entry.id),
|
|
queryFn: () => issuesApi.get(entry.id),
|
|
retry: false,
|
|
staleTime: 30_000,
|
|
})),
|
|
});
|
|
|
|
const issueById = new Map(detailQueries.flatMap((query) => query.data ? [[query.data.id, query.data] as const] : []));
|
|
const refreshedEntries = entries.map((entry) => {
|
|
const issue = issueById.get(entry.id);
|
|
if (!issue || issue.companyId !== companyId || issue.hiddenAt) return entry;
|
|
return {
|
|
...entry,
|
|
title: issue.title,
|
|
identifier: issue.identifier,
|
|
status: issue.status,
|
|
};
|
|
});
|
|
const queryRevision = detailQueries
|
|
.map((query) => `${query.dataUpdatedAt}:${query.errorUpdatedAt}:${query.status}`)
|
|
.join("|");
|
|
|
|
useEffect(() => {
|
|
if (!storageKey || !companyId || detailQueries.length === 0) return;
|
|
|
|
const resolvedIssues: Issue[] = [];
|
|
const removeIds = new Set<string>();
|
|
detailQueries.forEach((query, index) => {
|
|
const entry = queryEntries[index];
|
|
if (!entry) return;
|
|
if (query.data) {
|
|
if (query.data.companyId !== companyId || query.data.hiddenAt) {
|
|
removeIds.add(entry.id);
|
|
} else {
|
|
resolvedIssues.push(query.data);
|
|
}
|
|
} else if (query.error instanceof ApiError && [403, 404].includes(query.error.status)) {
|
|
removeIds.add(entry.id);
|
|
}
|
|
});
|
|
|
|
updateRecentTaskSnapshots(storageKey, companyId, resolvedIssues);
|
|
pruneRecentTasks(storageKey, companyId, removeIds);
|
|
// queryRevision is the stable notification boundary for the useQueries result array.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [companyId, entries, queryRevision, storageKey]);
|
|
|
|
const [settledOrder, setSettledOrder] = useState(() => entries.map((entry) => entry.id));
|
|
const membership = JSON.stringify(queryEntries.map((entry) => entry.id));
|
|
const activityRevision = JSON.stringify(entries.map((entry) => [entry.id, entry.recordedAt]));
|
|
useEffect(() => {
|
|
const latestOrder = (JSON.parse(activityRevision) as Array<[string, number]>).map(([id]) => id);
|
|
// Additions and removals are immediate. Only activity-driven moves wait for quiet.
|
|
if (JSON.stringify([...settledOrder].sort((a, b) => a.localeCompare(b))) !== membership) {
|
|
setSettledOrder(latestOrder);
|
|
return;
|
|
}
|
|
if (latestOrder.every((id, index) => id === settledOrder[index])) return;
|
|
const timeout = window.setTimeout(() => setSettledOrder(latestOrder), RECENT_TASK_ORDER_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [activityRevision, membership, settledOrder, storageKey]);
|
|
|
|
const entryById = new Map(refreshedEntries.map((entry) => [entry.id, entry]));
|
|
const hasSameMembership = settledOrder.length === entries.length && settledOrder.every((id) => entryById.has(id));
|
|
|
|
return {
|
|
entries: hasSameMembership ? settledOrder.map((id) => entryById.get(id)!) : refreshedEntries,
|
|
storageKey,
|
|
};
|
|
}
|