Лента: окно «до сейчас» при перечитывании и перечитывание при возврате на вкладку (#77, C-1, C-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
066cb79c4f
commit
820adbe92f
@ -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`).
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user