Веб-приложение: раскладка и флаги доски отдельно для каждого проекта, фильтр спринтов по данным (#61)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 21:32:09 +04:00
parent fb680afb63
commit 57145b22c9
8 changed files with 101 additions and 9 deletions

View File

@ -298,3 +298,4 @@
в `App.test.tsx` ожидание «/app ведёт на доску» — через `waitFor`.** Обоснование: без `/projects` оболочка не
показывает страницы; `findByTestId` ловил заглушку «Загрузка настроек», которую страница сменяет другим элементом с
тем же `data-testid`, и проверка падала на оторванном узле. Цена ошибки: только тесты.
35. 2026-10-19 — **в тесте `storage.test.ts` выражение `{…} as JsonImport` заменено на `as unknown as JsonImport`.** Обоснование: `tsc --noEmit` (шаг `make check`) отвергает прямое приведение частичного объекта к `JsonImport` (нет `model`, `dateColumns`, `collapsedLanes`); в тесте важны только `layout`, `layoutByDate`, `laneOrder`, `filters`, и `applyJsonSettings` читает только их. Цена ошибки: тест передаёт неполный импорт; если `applyJsonSettings` начнёт читать другие поля, тест выдаст неверный результат, а не ошибку типа.

View File

@ -26,7 +26,12 @@ function hub(over: Record<string, () => Response> = {}) {
return json(404, { error: "not_found" });
});
}
const page = () => renderWithProviders(<BoardPage project="AWPCA" />, { route: "/board", path: "/board" });
const page = (project = "AWPCA") => renderWithProviders(<BoardPage project={project} />, { route: "/board", path: "/board" });
// канбан-проект: спринтов нет ни у одной своей задачи (соседи — из другого проекта — могут иметь)
const withSprints = (keep: (key: string) => boolean) => ({ ...SNAP, issues: SNAP.issues.map((i: { key: string; sprint: string | null }) => ({ ...i, sprint: keep(i.key) ? i.sprint : null })) });
const KANBAN = withSprints(() => false);
const NEIGHBOR_ONLY = withSprints((k) => k === "AURORA-9007");
const NO_SPRINT_TEXT = "колонки «Спринт» нет — показаны все задачи";
describe("страница доски", () => {
beforeEach(() => setToken("tok"));
@ -166,4 +171,39 @@ describe("страница доски", () => {
expect(screen.getByRole("group", { name: "Все исполнители" })).toBeInTheDocument();
expect(screen.queryByRole("listbox")).toBeNull();
});
it("T9: старая раскладка переносится только в проект AWPCA", async () => {
hub();
localStorage.setItem("graph-pos:old.json", '{"AURORA-9001":{"x":1,"ry":40}}');
const r = page("STOCKS_DRRK");
await screen.findByText(/^Хаб · данные на/);
expect(localStorage.getItem("graph-pos:board@STOCKS_DRRK")).toBeNull();
expect(localStorage.getItem("graph-pos:board")).toBeNull();
r.unmount();
page("AWPCA");
await screen.findByText(/^Хаб · данные на/);
expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-9001":{"x":1,"ry":40}}');
});
it("T9: канбан-проект — без спринтов в снимке: надпись вместо фильтра спринтов", async () => {
hub({ "GET /board": () => json(200, KANBAN) });
page("STOCKS_DRRK");
expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull();
});
it("T9: канбан-проект, спринт только у соседа — надпись, карточка соседа на доске", async () => {
hub({ "GET /board": () => json(200, NEIGHBOR_ONLY) });
page("STOCKS_DRRK");
expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull();
expect(screen.getAllByText("AURORA-9007").length).toBeGreaterThan(0);
});
it("T9: проект AWPCA с фикстурой hub-basic — выбор спринта есть", async () => {
hub();
page("AWPCA");
expect(await screen.findByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument();
expect(screen.queryByText(NO_SPRINT_TEXT)).toBeNull();
});
});

View File

@ -21,7 +21,7 @@ export function BoardPage({ project }: { project: string }) {
const closedStatuses = useClosedStatuses();
const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]);
const src = useBoardSource(d && !d.missing.length ? d.dicts : null, project);
const prefs = useBoardPrefs(src.model);
const prefs = useBoardPrefs(src.model, project);
const today = todayIso();
const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today),
[src.model, prefs.filters, prefs.store.laneOrder, today]);

View File

@ -26,5 +26,7 @@ export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel {
});
const missing = addStubs(nodes, links, NOT_IN_HUB);
const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"];
return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint: true, missing, stubLane: NOT_IN_HUB };
// фильтр спринтов — только по своим задачам: спринт соседа из другого проекта не делает канбан-проект «спринтовым»
const hasSprint = snap.issues.some((it) => !it.neighbor && !!it.sprint);
return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB };
}

View File

@ -21,7 +21,16 @@ describe("модель доски = links.html@3931a88", () => {
});
it("fromHub — пустое зеркало без полного прогона", () => {
const snap = JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot;
expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-empty.expected.json").model);
// эталон написан, когда fromHub всегда давал hasSprint: true; без своих спринтов фильтр спринтов не показывается
expect(canon(fromHub(snap, dicts))).toEqual({ ...golden("hub-empty.expected.json").model, hasSprint: false });
});
it("T9: fromHub — спринта у своих задач нет (есть только у соседа) → hasSprint false", () => {
const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot;
const none = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: null })) };
expect(fromHub(none, dicts).hasSprint).toBe(false);
const neighborOnly = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: i.neighbor ? i.sprint : null })) };
expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false);
expect(fromHub(snap, dicts).hasSprint).toBe(true);
});
it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => {
const text = fx("jira-export.csv");

View File

@ -11,7 +11,7 @@ import storageCases from "./__fixtures__/storage.cases.json";
import { computeLayout, measuredHeight } from "./layout/layout.ts";
import { fromHub } from "./model/fromHub.ts";
import { fromJson } from "./model/fromJson.ts";
import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, toggleLane, writeFlag } from "./storage.ts";
import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, setStoreProject, toggleLane, writeFlag } from "./storage.ts";
import type { BoardModel, JsonImport } from "./types.ts";
import { noFilters } from "./types.ts";
@ -126,3 +126,31 @@ describe("операции хранилища", () => {
expect(() => { toggleLane("A"); resetLayout(true); setColWidth("2026-10-20", 400); }).not.toThrow();
});
});
describe("ключи по проектам", () => {
beforeEach(() => localStorage.clear());
afterEach(() => setStoreProject(null));
it("AWPCA — прежние ключи", () => {
setStoreProject("AWPCA");
writeFlag("crit", true); toggleLane("Эпик 1");
expect(localStorage.getItem("graph-crit")).toBe("1");
expect(localStorage.getItem("graph-fold:board")).toBe('["Эпик 1"]');
});
it("другой проект — ключи с суффиксом, AWPCA не тронут", () => {
localStorage.setItem("graph-pos:board", '{"AURORA-1":{"x":1,"ry":2}}');
setStoreProject("STOCKS_DRRK");
writeFlag("crit", true); toggleLane("Эпик 1"); setColWidth("2026-10-20", 400);
saveLayout({ "AURORA-2": { x: 10, ry: 20, w: 200, h: 80, auto: false } }, {}, false);
applyJsonSettings({ layout: { "AURORA-3": { x: 1, ry: 1 } }, layoutByDate: {}, laneOrder: ["A"], filters: { neighbors: true } } as unknown as JsonImport);
expect(localStorage.getItem("graph-crit@STOCKS_DRRK")).toBe("1");
expect(localStorage.getItem("graph-fold:board@STOCKS_DRRK")).toBe('["Эпик 1"]');
expect(localStorage.getItem("graph-lanes:board@STOCKS_DRRK")).toBe('["A"]');
expect(localStorage.getItem("graph-neighbors@STOCKS_DRRK")).toBe("1");
expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-1":{"x":1,"ry":2}}');
expect(localStorage.getItem("graph-crit")).toBeNull();
expect(laneOrder()).toEqual(["A"]);
setStoreProject("AWPCA");
expect(laneOrder()).toEqual([]);
});
});

View File

@ -1,6 +1,8 @@
// хранилище настроек доски: ключи и формат — как у links.html@3931a88 (прежние настройки пользователя продолжают работать)
// localStorage: общие настройки «graph-<имя>», настройки доски «graph-<имя>:board» (раскладка, дорожки, фильтры). Ключ не зависит от имени
// файла: карточки адресуются ключами Jira, поэтому расстановка переживает замену данных. Любое обращение защищено от недоступного хранилища
// ключи по проектам (решения 10 и 20): раскладка и флаги доски — отдельно для каждого проекта; у AWPCA ключи прежние, у остальных — суффикс «@<метка>»
// (graph-pos:board@STOCKS_DRRK). Все функции модуля берут ключи через pref()/fileKey() и текущий набор (setStoreProject)
import { COLW, GRID } from "./constants.ts";
import type { RelBox, Layout } from "./layout/layout.ts";
import type { Filters, JsonImport } from "./types.ts";
@ -12,13 +14,19 @@ export const store = {
del(k: string): void { try { localStorage.removeItem(k); } catch { /* хранилище недоступно */ } },
json<T>(k: string, d: T): T { try { const v = JSON.parse(store.get(k, "null") as string); return v === null ? d : v as T; } catch { return d; } },
};
export const pref = (name: string): string => "graph-" + name;
export const LEGACY_PROJECT = "AWPCA"; // у него ключи без суффикса — раскладка, сделанная до проектов, сохраняется
export function storeSuffix(label: string | null): string { return label === null || label === LEGACY_PROJECT ? "" : "@" + label; }
let suffix = ""; // набор ключей текущего проекта
export function setStoreProject(label: string | null): void { suffix = storeSuffix(label); }
export const isLegacyStore = (): boolean => suffix === "";
export const pref = (name: string): string => "graph-" + name + suffix;
const BOARD = "board", BOARD_KEYS = ["pos", "lanes", "fold", "sprint", "who"];
export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD;
export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD + suffix;
export const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
const num = (v: unknown): boolean => typeof v === "number" && Number.isFinite(v);
// миграции — только для набора AWPCA (isLegacyStore): у остальных проектов старых ключей не было
// до 11.10.2026 настройки лежали под именем файла («graph-pos:<файл>») и терялись при каждой новой выгрузке; при первом запуске новой версии
// переносим на общий ключ набор того файла, где расставлено больше всего карточек
export function migrateStore(): void {

View File

@ -23,8 +23,12 @@ const pick = (model: BoardModel, f: Filters) =>
({ model, sprints: initialSprints(model, f, st.rememberedSprints()), who: initialWho(model, f, st.rememberedWho()) });
// настройки доски из хранилища links.html: флаги, скрытые виды и типы, выбор спринтов и исполнителей, раскладка
export function useBoardPrefs(model: BoardModel): BoardPrefsApi {
const [prefs, setPrefs] = useState<st.BoardPrefs>(() => { st.migrateMode(); st.migrateStore(); return st.readPrefs(); });
export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi {
const [prefs, setPrefs] = useState<st.BoardPrefs>(() => {
st.setStoreProject(project); // ключи проекта — до любого чтения; страница пересоздаётся при смене проекта (key={project})
if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); }
return st.readPrefs();
});
const [store, setStore] = useState<LayoutStore>(() => readStore(0));
const [sel, setSel] = useState(() => pick(model, prefs.filters));
if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho