Лента: окно «до сейчас» при перечитывании и перечитывание при возврате на вкладку (#77, C-1, C-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 11:41:51 +04:00
parent 066cb79c4f
commit 820adbe92f
4 changed files with 53 additions and 16 deletions

View File

@ -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`).

View File

@ -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<ChangesResponse>("/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<ChangesResponse>("/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<HubSettings>("/settings"), staleTime: Infinity });

View File

@ -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: [

View File

@ -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 (
<div data-testid="page-feed" className="feed">