Лента: окно «до сейчас» при перечитывании и перечитывание при возврате на вкладку (#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 символов одной строкой), без вики-разметки: лента — обзор, полный текст
|
5. **Комментарий в ленте — простой текст** (300 символов одной строкой), без вики-разметки: лента — обзор, полный текст
|
||||||
— в карточке.
|
— в карточке.
|
||||||
6. **История в карточке — 10 последних с «Показать все»**; блок в основной колонке после комментариев.
|
6. **История в карточке — 10 последних с «Показать все»**; блок в основной колонке после комментариев.
|
||||||
7. **Перечитывание**: ключ запроса `["changes", …]`; `RefreshProvider` после пакета инвалидирует `["changes"]`; TanStack
|
7. **Перечитывание**: ключ запроса `["changes", {project, period, kinds}]`, границы периода считаются в момент запроса
|
||||||
Query перечитывает при возврате на вкладку (`refetchOnWindowFocus` по умолчанию).
|
(окно всегда «до сейчас»); `RefreshProvider` после пакета инвалидирует `["changes"]`; при возврате на вкладку —
|
||||||
|
`refetchOnWindowFocus: true` у `useChanges` (в клиенте приложения по умолчанию оно выключено). Подзаголовок берёт
|
||||||
|
окно из ответа хаба.
|
||||||
|
|
||||||
## Decisions made without discussion
|
## Decisions made without discussion
|
||||||
|
|
||||||
1. 2026-10-22 — **все решения выше (1–7) — мои**: владелец сказал «начинай» без обсуждения интерфейса. Цена ошибки:
|
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,
|
changes: (q: ChangesQuery) => ["changes", q] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// лента изменений: границы — ISO (toISOString), kinds — через запятую; limit — максимум хаба (design web-feed #3)
|
// лента изменений: в ключе — проект, период и виды; границы считаются в момент запроса (bounds), поэтому перечитывание
|
||||||
export interface ChangesQuery { project: string; since: string; until: string; kinds: string }
|
// (после «Обновить из 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 CHANGES_LIMIT = 2000;
|
||||||
export const useChanges = (q: ChangesQuery, enabled = true) =>
|
export const useChanges = (q: ChangesQuery, bounds: () => { since: Date; until: Date }, enabled = true) =>
|
||||||
useQuery({ queryKey: qk.changes(q), enabled, queryFn: () => api.get<ChangesResponse>("/changes", { ...q, limit: CHANGES_LIMIT }) });
|
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 = () =>
|
export const useSettings = () =>
|
||||||
useQuery({ queryKey: qk.settings, queryFn: () => api.get<HubSettings>("/settings"), staleTime: Infinity });
|
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 { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createMemoryRouter } from "react-router";
|
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 { App } from "../App.tsx";
|
||||||
import type { ChangeEvent } from "../api/types.ts";
|
import type { ChangeEvent } from "../api/types.ts";
|
||||||
import { setToken } from "../auth/token.ts";
|
import { setToken } from "../auth/token.ts";
|
||||||
@ -51,6 +53,22 @@ const page = () => screen.findByTestId("page-feed");
|
|||||||
|
|
||||||
describe("лента изменений", () => {
|
describe("лента изменений", () => {
|
||||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
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 () => {
|
it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => {
|
||||||
const calls = hub(() => ({ changes: [
|
const calls = hub(() => ({ changes: [
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import { useChanges } from "../api/queries.ts";
|
import { useChanges } from "../api/queries.ts";
|
||||||
import type { ChangeKind } from "../api/types.ts";
|
import type { ChangeKind } from "../api/types.ts";
|
||||||
@ -9,7 +9,7 @@ import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, peri
|
|||||||
import "./feed.css";
|
import "./feed.css";
|
||||||
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
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 {
|
function plural(n: number): string {
|
||||||
const m10 = n % 10, m100 = n % 100;
|
const m10 = n % 10, m100 = n % 100;
|
||||||
@ -23,18 +23,16 @@ export function FeedPage({ project }: { project: string }) {
|
|||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const period = parsePeriod(params.get("period"));
|
const period = parsePeriod(params.get("period"));
|
||||||
const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]);
|
const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]);
|
||||||
// «сейчас» фиксируется на смену периода: границы запроса стабильны, ключ кэша не меняется на каждой отрисовке
|
// границы считает запрос в момент выполнения (C-1): ключ кэша — проект, период и виды
|
||||||
const [now, setNow] = useState(() => new Date());
|
const changes = useChanges({ project, period, kinds: kinds.join(",") }, () => periodRange(period, new Date()), kinds.length > 0);
|
||||||
const range = useMemo(() => periodRange(period, now), [period, now]);
|
const now = new Date(changes.dataUpdatedAt || Date.now()); // «текущие сутки» для времени событий — на момент ответа
|
||||||
const changes = useChanges({ project, since: range.since.toISOString(), until: range.until.toISOString(), kinds: kinds.join(",") },
|
|
||||||
kinds.length > 0);
|
|
||||||
|
|
||||||
const setParam = (name: string, value: string | null) => setParams((p) => {
|
const setParam = (name: string, value: string | null) => setParams((p) => {
|
||||||
const n = new URLSearchParams(p);
|
const n = new URLSearchParams(p);
|
||||||
if (value) n.set(name, value); else n.delete(name);
|
if (value) n.set(name, value); else n.delete(name);
|
||||||
return n;
|
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 toggleKind = (k: ChangeKind, on: boolean) => {
|
||||||
const next = on ? [...kinds, k] : kinds.filter((x) => x !== k);
|
const next = on ? [...kinds, k] : kinds.filter((x) => x !== k);
|
||||||
setParam("kinds", next.length ? kindsParam(next) : "none"); // пустой набор — явное «none», иначе адрес вернул бы умолчание
|
setParam("kinds", next.length ? kindsParam(next) : "none"); // пустой набор — явное «none», иначе адрес вернул бы умолчание
|
||||||
@ -43,7 +41,8 @@ export function FeedPage({ project }: { project: string }) {
|
|||||||
const data = changes.data;
|
const data = changes.data;
|
||||||
const groups = useMemo(() => groupByIssue(data?.changes ?? []), [data]);
|
const groups = useMemo(() => groupByIssue(data?.changes ?? []), [data]);
|
||||||
const count = data?.changes.length ?? 0;
|
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 (
|
return (
|
||||||
<div data-testid="page-feed" className="feed">
|
<div data-testid="page-feed" className="feed">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user