diff --git a/web/src/modules/jira/Summary.tsx b/web/src/modules/jira/Summary.tsx
index 477bdf0..18a249a 100644
--- a/web/src/modules/jira/Summary.tsx
+++ b/web/src/modules/jira/Summary.tsx
@@ -3,21 +3,18 @@ import type { Project } from "../../api/types.ts";
import { Notice } from "../../ui/index.ts";
import { useBoard } from "./api/queries.ts";
import { isClosed, useClosedStatuses } from "./api/closed.ts";
+import { isOverdue, todayISO } from "./issues/card.ts";
import "./summary.css";
-// сегодня по местному времени, ГГГГ-ММ-ДД: срок задачи Jira — дата без времени
-const today = (now = new Date()): string =>
- `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
-
// сводка jira для «Обзора»: свои задачи проекта (не соседи), из них открытые и просроченные — по снимку доски
export function Summary({ project }: { project: Project }) {
const board = useBoard(project.label);
const closed = useClosedStatuses();
const q = `?project=${encodeURIComponent(project.label)}`;
const own = board.data?.issues.filter((i) => !i.neighbor);
+ const t = todayISO();
const open = own?.filter((i) => !isClosed(i.status, closed));
- const t = today();
- const late = open?.filter((i) => !!i.duedate && i.duedate.slice(0, 10) < t).length ?? 0;
+ const late = own?.filter((i) => isOverdue(i.duedate?.slice(0, 10) ?? "", i.status, closed, t)).length ?? 0;
return (
{board.isError ? {board.error.message}
diff --git a/web/src/shell/NewProject.test.tsx b/web/src/shell/NewProject.test.tsx
index 792d032..ce5caac 100644
--- a/web/src/shell/NewProject.test.tsx
+++ b/web/src/shell/NewProject.test.tsx
@@ -25,7 +25,7 @@ describe("NewProject: пакеты каждому своему модулю", ()
await userEvent.type(screen.getByLabelText("Название"), "Пэ");
await userEvent.click(screen.getByRole("button", { name: "Создать" }));
await waitFor(() => expect(select).toHaveBeenCalledWith("P"));
- expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3);
- expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4);
+ expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3, "P");
+ expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4, "P");
});
});
diff --git a/web/src/shell/NewProject.tsx b/web/src/shell/NewProject.tsx
index daeee69..940fb90 100644
--- a/web/src/shell/NewProject.tsx
+++ b/web/src/shell/NewProject.tsx
@@ -6,7 +6,7 @@ export function NewProject({ onClose, select }: { onClose(): void; select(label:
const track = useTrackModuleBatch();
return {
onClose();
- for (const b of batches) track(b.module, b.id);
+ for (const b of batches) track(b.module, b.id, p.label); // пакет принадлежит новому проекту: смена проекта его не сбрасывает
select(p.label);
}} />;
}
diff --git a/web/src/shell/activity/ActivityProvider.tsx b/web/src/shell/activity/ActivityProvider.tsx
index 7e13e46..bf64571 100644
--- a/web/src/shell/activity/ActivityProvider.tsx
+++ b/web/src/shell/activity/ActivityProvider.tsx
@@ -12,7 +12,7 @@ import type { HubModule } from "../../modules/types.ts";
import { ActivityContext, AGENTS_KEY, type ActivityValue, type ModuleActivity } from "./activity.ts";
import { DONE_VISIBLE_MS, MAX_POLL_ERRORS, PICKUP_LIMIT, PICKUP_MS, POLL_MS, type ModuleEvent } from "./events.ts";
-interface Active { id: number; agentOffline: boolean; own: boolean } // own — пакет действия обновления этого модуля
+interface Active { id: number; agentOffline: boolean; own: boolean; project: string | null } // own — пакет действия обновления этого модуля; project — проект, которому принадлежит пакет
const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled");
const fetchAgents = () => api.get<{ agents: AgentView[] }>("/agents").then((r) => r.agents);
@@ -65,7 +65,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
const open = r.batches.filter((b) => (b.status === "queued" || b.status === "running") && b.id !== skip).sort((a, b) => a.id - b.id)[0];
if (!open) return;
failures.current = 0;
- setActiveNow({ id: open.id, agentOffline: offlineNow(), own: false });
+ setActiveNow({ id: open.id, agentOffline: offlineNow(), own: false, project: projectRef.current });
})().catch(() => { /* не критично: подхватим при следующем проходе */ }).finally(() => { if (gen === generation.current) picking.current = false; });
// eslint-disable-next-line react-hooks/exhaustive-deps -- offlineNow читает ref, имя и охват модуля постоянны
}, [refresh, scoped, name, setActiveNow]);
@@ -75,7 +75,8 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
useEffect(() => {
if (prevProject.current !== project) {
prevProject.current = project;
- if (scoped) {
+ // пакет проекта, на который перешли (создание проекта: track до смены адреса), не сбрасывается — только чужой (M-2)
+ if (scoped && activeRef.current?.project !== project) {
generation.current += 1;
picking.current = false;
failures.current = 0;
@@ -161,6 +162,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
const start = useCallback(async () => {
const p = projectRef.current;
+ const gen = generation.current; // вместе с проектом, до любого await: пакет старого проекта в строке нового не отслеживается (M-1)
if (!refresh || (scoped && p === null)) return;
if (startingRef.current || activeRef.current?.own) return;
startingRef.current = true;
@@ -169,7 +171,6 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
try {
const list = await qc.fetchQuery({ queryKey: AGENTS_KEY, queryFn: fetchAgents, staleTime: 0 });
const offline = !!status.agent && list.find((a) => a.module === status.agent)?.online === false;
- const gen = generation.current;
let b: { id: number; estimate_seconds?: number };
try {
b = await refresh.start(p ?? "");
@@ -179,7 +180,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
}
if (gen !== generation.current) return; // проект сменился во время постановки: пакет не этого проекта
failures.current = 0;
- setActiveNow({ id: b.id, agentOffline: offline, own: true });
+ setActiveNow({ id: b.id, agentOffline: offline, own: true, project: p });
setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds ?? 0, agentOffline: offline, retrying: false });
} catch (e) {
report(e);
@@ -190,17 +191,21 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
}
}, [refresh, scoped, status, qc, report, pickup, setActiveNow]);
- const track = useCallback((id: number) => {
+ const track = useCallback((id: number, forProject?: string) => {
if (activeRef.current?.own) return;
failures.current = 0;
- setActiveNow({ id, agentOffline: false, own: true });
+ setActiveNow({ id, agentOffline: false, own: true, project: forProject ?? projectRef.current });
setEvent({ kind: "batch", id, phase: "queued", remaining: 0, agentOffline: false, retrying: false });
}, [setActiveNow]);
const running = active && batch.data?.id === active.id ? batch.data : null;
const busy = starting || !!active?.own;
- const view = useMemo(() => ({ agent, event, busy, running, start, track }),
- [agent, event, busy, running, start, track]);
+ // M-7: «агент офлайн» подхваченного пакета — по текущему состоянию агента, а не по снимку в момент подхвата
+ const shown = event?.kind === "batch" && !event.agentOffline && agent?.online === false && active?.id === event.id
+ ? { ...event, agentOffline: true } : event;
+ const view = useMemo(() => ({ agent, event: shown, busy, running, start, track }),
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- shown зависит от event, agent и active
+ [agent, event, agent?.online, active?.id, busy, running, start, track]);
return { view, tracking: starting || active !== null };
}
diff --git a/web/src/shell/activity/activity.test.tsx b/web/src/shell/activity/activity.test.tsx
index 89598ab..8e0e12a 100644
--- a/web/src/shell/activity/activity.test.tsx
+++ b/web/src/shell/activity/activity.test.tsx
@@ -337,6 +337,37 @@ describe("ActivityProvider", () => {
expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 });
});
+ it("M-2: пакет, отданный track для нового проекта, переживает смену проекта на него; чужой — сбрасывается", async () => {
+ hub({ ...byId({ 9: "wiki_sync" }, () => "running") });
+ const m = mount();
+ await agentsReady(m);
+ act(() => m.now().wiki.track(9, "NEW"));
+ m.setProject("NEW");
+ await sleep(40);
+ expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 });
+ m.setProject("OTHER");
+ await waitFor(() => expect(m.now().wiki.event).toBeNull());
+ });
+
+ it("M-1: проект сменился во время запроса агентов — пакет старого проекта в строке нового не отслеживается", async () => {
+ const agents = deferred();
+ let first = true;
+ hub({
+ "GET /agents": async () => { if (!first) await agents.promise; first = false; return json(200, { agents: [agentView("jira"), agentView("wiki")] }); },
+ "POST /wiki/sync": () => json(201, { batch_id: 11 }),
+ ...byId({ 11: "wiki_sync" }, () => "running"),
+ });
+ const m = mount();
+ await agentsReady(m);
+ let started!: Promise;
+ act(() => { started = m.now().wiki.start(); });
+ m.setProject("STOCKS_DRRK");
+ agents.resolve();
+ await act(() => started);
+ expect(m.now().wiki.event).toBeNull();
+ expect(m.now().wiki.busy).toBe(false);
+ });
+
it("wiki 409 tracks running batch: постановка получила 409 — подхвачен идущий пакет, события ошибки нет", async () => {
let conflict = false;
const h = hub({
diff --git a/web/src/shell/activity/activity.ts b/web/src/shell/activity/activity.ts
index acfe89f..da4199e 100644
--- a/web/src/shell/activity/activity.ts
+++ b/web/src/shell/activity/activity.ts
@@ -9,7 +9,7 @@ export interface ModuleActivity {
busy: boolean; // запрос постановки в пути или отслеживаемый пакет — пакет действия обновления модуля
running: Batch | null; // отслеживаемый пакет (последний ответ опроса)
start(): Promise; // «обновить» текущий проект модуля; без проекта у модуля с охватом «проект» — ничего
- track(batchId: number): void; // опрашивать уже поставленный пакет (после создания проекта)
+ track(batchId: number, project?: string): void; // опрашивать уже поставленный пакет (после создания проекта); project — его проект, по умолчанию текущий
}
export interface ActivityValue {
@@ -33,9 +33,9 @@ export function useModuleActivity(name: string): ModuleActivity {
return m;
}
// отслеживать пакет, поставленный хабом конкретному модулю (после создания/правки проекта); неизвестный модуль пропускается
-export function useTrackModuleBatch(): (module: string, batchId: number) => void {
+export function useTrackModuleBatch(): (module: string, batchId: number, project?: string) => void {
const { modules } = useActivity();
- return useCallback((module, id) => { modules[module]?.track(id); }, [modules]);
+ return useCallback((module, id, project) => { if (project === undefined) modules[module]?.track(id); else modules[module]?.track(id, project); }, [modules]);
}
export const useAgents = (): AgentView[] => useActivity().agents;
export const useActivitySummary = (): ActivityValue["summary"] => useActivity().summary;