diff --git a/openspec/changes/web-feed/design.md b/openspec/changes/web-feed/design.md index eae81da..52b6675 100644 --- a/openspec/changes/web-feed/design.md +++ b/openspec/changes/web-feed/design.md @@ -18,10 +18,22 @@ Jira» — `shell/RefreshProvider.tsx`. Хаб: `GET /changes` (`since`/`until` 5. **Комментарий в ленте — простой текст** (300 символов одной строкой), без вики-разметки: лента — обзор, полный текст — в карточке. 6. **История в карточке — 10 последних с «Показать все»**; блок в основной колонке после комментариев. -7. **Перечитывание**: ключ запроса `["changes", …]`; `RefreshProvider` после пакета инвалидирует `["changes"]`; TanStack - Query перечитывает при возврате на вкладку (`refetchOnWindowFocus` по умолчанию). +7. **Перечитывание**: ключ запроса `["changes", {project, period, kinds}]`, границы периода считаются в момент запроса + (окно всегда «до сейчас»); `RefreshProvider` после пакета инвалидирует `["changes"]`; при возврате на вкладку — + `refetchOnWindowFocus: true` у `useChanges` (в клиенте приложения по умолчанию оно выключено). Подзаголовок берёт + окно из ответа хаба. ## Decisions made without discussion 1. 2026-10-22 — **все решения выше (1–7) — мои**: владелец сказал «начинай» без обсуждения интерфейса. Цена ошибки: переделка вёрстки страницы; бэкенд не затронут. +2. 2026-10-22 — **R-1: смена проекта на ленте сохраняет `period` и `kinds`** (`Layout.select`). Обоснование: они от + проекта не зависят; фильтры доски и списка по-прежнему сбрасываются. Цена ошибки: нет. +3. 2026-10-22 — **R-2: снятые все флажки пишутся в адрес как `kinds=none`.** Обоснование: пустое значение означало бы + набор по умолчанию. Цена ошибки: «none» виден в адресе. +4. 2026-10-22 — **R-3: в e2e флажок — щелчок и ожидание `toBeChecked`, а не `locator.check`.** Обоснование: флажок + управляемый, состояние приходит из адреса после навигации роутера. Цена ошибки: нет. +5. 2026-10-22 — **R-4 (по C-1/C-2 финального ревью): границы периода — в момент запроса, ключ кэша без них; + `refetchOnWindowFocus: true` у ленты.** Обоснование: замороженная при открытии граница «до» не давала перечитыванию + показать новые события, а фокус вкладки в приложении не перечитывал ничего. Цена ошибки: лишний запрос `/changes` + при возврате на вкладку (не чаще раза в 30 с — `staleTime`). diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 3e62024..913b83c 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -17,11 +17,19 @@ export const qk = { changes: (q: ChangesQuery) => ["changes", q] as const, }; -// лента изменений: границы — ISO (toISOString), kinds — через запятую; limit — максимум хаба (design web-feed #3) -export interface ChangesQuery { project: string; since: string; until: string; kinds: string } +// лента изменений: в ключе — проект, период и виды; границы считаются в момент запроса (bounds), поэтому перечитывание +// (после «Обновить из Jira», при возврате на вкладку) берёт окно «до сейчас», а не до открытия страницы (C-1, C-2); +// limit — максимум хаба (design web-feed #3) +export interface ChangesQuery { project: string; period: string; kinds: string } export const CHANGES_LIMIT = 2000; -export const useChanges = (q: ChangesQuery, enabled = true) => - useQuery({ queryKey: qk.changes(q), enabled, queryFn: () => api.get("/changes", { ...q, limit: CHANGES_LIMIT }) }); +export const useChanges = (q: ChangesQuery, bounds: () => { since: Date; until: Date }, enabled = true) => + useQuery({ + queryKey: qk.changes(q), enabled, refetchOnWindowFocus: true, + queryFn: () => { + const { since, until } = bounds(); + return api.get("/changes", { project: q.project, kinds: q.kinds, since: since.toISOString(), until: until.toISOString(), limit: CHANGES_LIMIT }); + }, + }); export const useSettings = () => useQuery({ queryKey: qk.settings, queryFn: () => api.get("/settings"), staleTime: Infinity }); diff --git a/web/src/feed/FeedPage.test.tsx b/web/src/feed/FeedPage.test.tsx index 42e5421..4ae56f7 100644 --- a/web/src/feed/FeedPage.test.tsx +++ b/web/src/feed/FeedPage.test.tsx @@ -3,7 +3,9 @@ import userEvent from "@testing-library/user-event"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { createMemoryRouter } from "react-router"; -import { beforeEach, describe, expect, it } from "vitest"; +import { focusManager } from "@tanstack/react-query"; +import { act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../App.tsx"; import type { ChangeEvent } from "../api/types.ts"; import { setToken } from "../auth/token.ts"; @@ -51,6 +53,22 @@ const page = () => screen.findByTestId("page-feed"); describe("лента изменений", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); + afterEach(() => { vi.useRealTimers(); focusManager.setFocused(undefined); }); + + it("C-1/C-2: возврат на вкладку перечитывает ленту с границей «до» на момент запроса", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 9, 22, 10, 0)); + const calls = hub(); + open("/app/feed?project=AWPCA"); + await page(); + await waitFor(() => expect(calls.length).toBe(1)); + expect(new Date(calls[0].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 10, 0).getTime()); + vi.setSystemTime(new Date(2026, 9, 22, 11, 30)); + act(() => { focusManager.setFocused(false); focusManager.setFocused(true); }); + await waitFor(() => expect(calls.length).toBe(2)); + expect(new Date(calls[1].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 11, 30).getTime()); + expect(new Date(calls[1].searchParams.get("since")!).getTime()).toBe(new Date(2026, 9, 22, 0, 0).getTime()); + }); it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => { const calls = hub(() => ({ changes: [ diff --git a/web/src/feed/FeedPage.tsx b/web/src/feed/FeedPage.tsx index c10b390..7523c32 100644 --- a/web/src/feed/FeedPage.tsx +++ b/web/src/feed/FeedPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useSearchParams } from "react-router"; import { useChanges } from "../api/queries.ts"; import type { ChangeKind } from "../api/types.ts"; @@ -9,7 +9,7 @@ import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, peri import "./feed.css"; const pad = (n: number) => String(n).padStart(2, "0"); -const stamp = (d: Date) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +const stamp = (d: Date) => isNaN(d.getTime()) ? "—" : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`; function plural(n: number): string { const m10 = n % 10, m100 = n % 100; @@ -23,18 +23,16 @@ export function FeedPage({ project }: { project: string }) { const [params, setParams] = useSearchParams(); const period = parsePeriod(params.get("period")); const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]); - // «сейчас» фиксируется на смену периода: границы запроса стабильны, ключ кэша не меняется на каждой отрисовке - const [now, setNow] = useState(() => new Date()); - const range = useMemo(() => periodRange(period, now), [period, now]); - const changes = useChanges({ project, since: range.since.toISOString(), until: range.until.toISOString(), kinds: kinds.join(",") }, - kinds.length > 0); + // границы считает запрос в момент выполнения (C-1): ключ кэша — проект, период и виды + const changes = useChanges({ project, period, kinds: kinds.join(",") }, () => periodRange(period, new Date()), kinds.length > 0); + const now = new Date(changes.dataUpdatedAt || Date.now()); // «текущие сутки» для времени событий — на момент ответа const setParam = (name: string, value: string | null) => setParams((p) => { const n = new URLSearchParams(p); if (value) n.set(name, value); else n.delete(name); return n; }); - const choosePeriod = (p: Period) => { setNow(new Date()); setParam("period", p === "today" ? null : p); }; + const choosePeriod = (p: Period) => setParam("period", p === "today" ? null : p); const toggleKind = (k: ChangeKind, on: boolean) => { const next = on ? [...kinds, k] : kinds.filter((x) => x !== k); setParam("kinds", next.length ? kindsParam(next) : "none"); // пустой набор — явное «none», иначе адрес вернул бы умолчание @@ -43,7 +41,8 @@ export function FeedPage({ project }: { project: string }) { const data = changes.data; const groups = useMemo(() => groupByIssue(data?.changes ?? []), [data]); const count = data?.changes.length ?? 0; - const subtitle = kinds.length && data ? `${count} ${plural(count)} · ${stamp(range.since)} — ${stamp(range.until)}` : undefined; + // окно — из ответа хаба: подзаголовок совпадает с тем, что показано + const subtitle = kinds.length && data ? `${count} ${plural(count)} · ${stamp(new Date(data.since))} — ${stamp(new Date(data.until))}` : undefined; return (