diff --git a/web/src/modules/jira/agile/agilePref.test.ts b/web/src/modules/jira/agile/agilePref.test.ts new file mode 100644 index 0000000..ccf2d4c --- /dev/null +++ b/web/src/modules/jira/agile/agilePref.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_VIEW, type AgileView } from "./filters.ts"; +import { AGILE_KEY, readView, saveView } from "./agilePref.ts"; + +const view = (over: Partial = {}): AgileView => ({ ...DEFAULT_VIEW, ...over }); +const stored = () => JSON.parse(localStorage.getItem(AGILE_KEY) ?? "null"); + +beforeEach(() => localStorage.clear()); +afterEach(() => vi.restoreAllMocks()); + +describe("agilePref", () => { + it("ключ хранилища — sdlc-hub-agile", () => { + expect(AGILE_KEY).toBe("sdlc-hub-agile"); + }); + + it("нет ключа → DEFAULT_VIEW", () => { + expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW); + }); + + it("запись AWPCA/backlog не меняет AWPCA/sprint и STOCKS/backlog", () => { + saveView("STOCKS", "backlog", view({ assignee: "petrov" })); + saveView("AWPCA", "sprint", view({ hideDone: true })); + saveView("AWPCA", "backlog", view({ epic: "AURORA-10", collapsed: ["AURORA-10"] })); + expect(readView("AWPCA", "backlog")).toEqual(view({ epic: "AURORA-10", collapsed: ["AURORA-10"] })); + expect(readView("AWPCA", "sprint")).toEqual(view({ hideDone: true })); + expect(readView("STOCKS", "backlog")).toEqual(view({ assignee: "petrov" })); + expect(readView("AWPCA", "kanban")).toEqual(DEFAULT_VIEW); + }); + + it("запись не хранит поиск q", () => { + saveView("AWPCA", "backlog", { ...view(), q: "секрет" } as AgileView); + expect(stored().AWPCA.backlog).not.toHaveProperty("q"); + }); + + it("мусор: «oops» → DEFAULT_VIEW", () => { + localStorage.setItem(AGILE_KEY, "oops"); + expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW); + }); + + it("мусор: «[1]» → DEFAULT_VIEW", () => { + localStorage.setItem(AGILE_KEY, "[1]"); + expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW); + }); + + it("мусор: {\"AWPCA\": 5} → DEFAULT_VIEW", () => { + localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: 5 })); + expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW); + }); + + it("поле неверного типа — дефолт поля, годные поля сохраняются", () => { + localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { collapsed: "x", hideDone: "yes", epic: "AURORA-10" } } })); + const v = readView("AWPCA", "backlog"); + expect(v.collapsed).toEqual([]); + expect(v.hideDone).toBe(false); + expect(v.epic).toBe("AURORA-10"); + }); + + it("массив с не-строками отбрасывается по элементам", () => { + localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { kanban: { hiddenColumns: ["Готово", 3, null] } } })); + expect(readView("AWPCA", "kanban").hiddenColumns).toEqual(["Готово"]); + }); + + it("epicsPanel: булево сохраняется; не-булево — дефолт true", () => { + localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { epicsPanel: false } } })); + expect(readView("AWPCA", "backlog").epicsPanel).toBe(false); + localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { epicsPanel: 0 } } })); + expect(readView("AWPCA", "backlog").epicsPanel).toBe(true); + }); + + it("saveView не бросает, если setItem бросает", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); }); + expect(() => saveView("AWPCA", "backlog", view({ hideDone: true }))).not.toThrow(); + }); + + it("readView → DEFAULT_VIEW, если getItem бросает", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); }); + expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW); + }); +}); diff --git a/web/src/modules/jira/agile/agilePref.ts b/web/src/modules/jira/agile/agilePref.ts new file mode 100644 index 0000000..b0cd0c8 --- /dev/null +++ b/web/src/modules/jira/agile/agilePref.ts @@ -0,0 +1,55 @@ +// запоминание вида страниц доски в localStorage: { "<метка>": { "backlog"|"sprint"|"kanban": AgileView } } +// любая ошибка хранилища — значения по умолчанию, наружу не выходит +import { DEFAULT_VIEW, type AgilePage, type AgileView } from "./filters.ts"; + +export const AGILE_KEY = "sdlc-hub-agile"; + +type Obj = Record; +const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v); +const str = (v: unknown, d: string): string => (typeof v === "string" ? v : d); +const bool = (v: unknown, d: boolean): boolean => (typeof v === "boolean" ? v : d); +const strArr = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []); + +// поле за полем: неверный тип — значение по умолчанию, годные поля сохраняются +function parseView(raw: unknown): AgileView { + if (!isObj(raw)) return { ...DEFAULT_VIEW, collapsed: [], hiddenColumns: [] }; + return { + assignee: str(raw.assignee, DEFAULT_VIEW.assignee), + epic: str(raw.epic, DEFAULT_VIEW.epic), + hideDone: bool(raw.hideDone, DEFAULT_VIEW.hideDone), + collapsed: strArr(raw.collapsed), + hiddenColumns: strArr(raw.hiddenColumns), + epicsPanel: bool(raw.epicsPanel, DEFAULT_VIEW.epicsPanel), + }; +} + +function readRoot(): Obj { + const raw = localStorage.getItem(AGILE_KEY); + if (raw === null) return {}; + const parsed: unknown = JSON.parse(raw); + return isObj(parsed) ? parsed : {}; +} + +export function readView(label: string, page: AgilePage): AgileView { + try { + const project = readRoot()[label]; + return parseView(isObj(project) ? project[page] : undefined); + } catch { + return parseView(undefined); + } +} + +export function saveView(label: string, page: AgilePage, v: AgileView): void { + try { + const root = readRoot(); + const project = isObj(root[label]) ? root[label] : {}; + const view: AgileView = { + assignee: v.assignee, epic: v.epic, hideDone: v.hideDone, + collapsed: v.collapsed, hiddenColumns: v.hiddenColumns, epicsPanel: v.epicsPanel, + }; + root[label] = { ...project, [page]: view }; + localStorage.setItem(AGILE_KEY, JSON.stringify(root)); + } catch { + // хранилище недоступно или переполнено — вид просто не запоминается + } +} diff --git a/web/src/modules/jira/agile/filters.test.ts b/web/src/modules/jira/agile/filters.test.ts new file mode 100644 index 0000000..58e4500 --- /dev/null +++ b/web/src/modules/jira/agile/filters.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts"; +import { + DEFAULT_VIEW, NONE, assigneeOptions, byRank, epicEntries, hasFilters, matches, parseSprintIds, selectableSprints, sprintIssues, +} from "./filters.ts"; + +const iss = (key: string, over: Partial = {}): AgileIssue => ({ + key, summary: `Тема ${key}`, issue_type: "Задача", status: "В работе", priority: null, assignee: null, + epic_key: null, story_points: null, rank: "0|a", done: false, ...over, +}); +const sprint = (over: Partial): AgileSprint => ({ + id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: "2026-10-26T09:00:00.000+0300", + complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over, +}); +const F = { q: "", assignee: "", epic: "", hideDone: false }; + +describe("matches", () => { + const a = iss("AURORA-1", { summary: "Тема про отчёт", assignee: "ivanov", epic_key: "AURORA-10" }); + it("пустой фильтр пропускает всё", () => { + expect(matches(iss("X-1", { done: true }), F)).toBe(true); + }); + it("q — подстрока без учёта регистра по key и summary, пробелы по краям отбрасываются", () => { + expect(matches(iss("AURORA-1"), { ...F, q: " aurora-1 " })).toBe(true); + expect(matches(iss("AURORA-10"), { ...F, q: " aurora-1 " })).toBe(true); + expect(matches(iss("AURORA-2"), { ...F, q: " aurora-1 " })).toBe(false); + expect(matches(a, { ...F, q: "тема" })).toBe(true); + expect(matches(a, { ...F, q: "ОТЧЁТ" })).toBe(true); + expect(matches(a, { ...F, q: "нет такого" })).toBe(false); + expect(matches(iss("X-1", { summary: null }), { ...F, q: "x-1" })).toBe(true); + }); + it("assignee: NONE — только null; точное совпадение иначе", () => { + expect(matches(iss("A-1", { assignee: null }), { ...F, assignee: NONE })).toBe(true); + expect(matches(iss("A-1", { assignee: "" }), { ...F, assignee: NONE })).toBe(false); + expect(matches(iss("A-1", { assignee: "ivanov" }), { ...F, assignee: NONE })).toBe(false); + expect(matches(a, { ...F, assignee: "ivanov" })).toBe(true); + expect(matches(a, { ...F, assignee: "petrov" })).toBe(false); + }); + it("epic: NONE — только epic_key === null; точное совпадение иначе", () => { + expect(matches(iss("A-1", { epic_key: null }), { ...F, epic: NONE })).toBe(true); + expect(matches(a, { ...F, epic: NONE })).toBe(false); + expect(matches(a, { ...F, epic: "AURORA-10" })).toBe(true); + expect(matches(a, { ...F, epic: "AURORA-11" })).toBe(false); + }); + it("hideDone скрывает выполненные", () => { + expect(matches(iss("A-1", { done: true }), { ...F, hideDone: true })).toBe(false); + expect(matches(iss("A-1"), { ...F, hideDone: true })).toBe(true); + }); + it("условия складываются по И", () => { + expect(matches(a, { q: "aurora", assignee: "ivanov", epic: "AURORA-10", hideDone: true })).toBe(true); + expect(matches(a, { q: "aurora", assignee: "ivanov", epic: "AURORA-11", hideDone: true })).toBe(false); + expect(matches(iss("A-1", { done: true, assignee: "ivanov" }), { ...F, assignee: "ivanov", hideDone: true })).toBe(false); + }); +}); + +describe("hasFilters", () => { + it("пустой фильтр — false, любое поле — true", () => { + expect(hasFilters(F)).toBe(false); + expect(hasFilters({ ...F, q: "x" })).toBe(true); + expect(hasFilters({ ...F, q: " " })).toBe(false); + expect(hasFilters({ ...F, assignee: "ivanov" })).toBe(true); + expect(hasFilters({ ...F, epic: NONE })).toBe(true); + expect(hasFilters({ ...F, hideDone: true })).toBe(true); + }); +}); + +describe("DEFAULT_VIEW и NONE", () => { + it("значения по ТЗ", () => { + expect(DEFAULT_VIEW).toEqual({ assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true }); + expect(NONE).toBe("-"); + }); +}); + +describe("assigneeOptions", () => { + it("«все», «Без исполнителя», затем исполнители по алфавиту ru", () => { + const issues = [iss("A-1", { assignee: "Яковлев" }), iss("A-2", { assignee: null }), iss("A-3", { assignee: "Афанасьев" }), iss("A-4", { assignee: "Афанасьев" })]; + expect(assigneeOptions(issues, "")).toEqual([ + { value: "", label: "все" }, + { value: NONE, label: "Без исполнителя" }, + { value: "Афанасьев", label: "Афанасьев" }, + { value: "Яковлев", label: "Яковлев" }, + ]); + }); + it("current, которого нет среди исполнителей, — в конце с подписью = значению", () => { + const opts = assigneeOptions([iss("A-1", { assignee: "ivanov" })], "gone"); + expect(opts.at(-1)).toEqual({ value: "gone", label: "gone" }); + expect(opts.map((o) => o.value)).toEqual(["", NONE, "ivanov", "gone"]); + }); + it("current, который есть, не дублируется", () => { + const opts = assigneeOptions([iss("A-1", { assignee: "ivanov" })], "ivanov"); + expect(opts.map((o) => o.value)).toEqual(["", NONE, "ivanov"]); + }); +}); + +describe("epicEntries", () => { + // сценарий «Фильтр по эпику»: AURORA-10 — 3 в бэклоге и 2 в активном спринте → count 5 + const epic = iss("AURORA-10", { issue_type: "Эпик", summary: "Большой эпик", done: false, rank: "0|b" }); + const data = (over: Partial = {}): AgileResponse => ({ + board: null, + sprints: [ + sprint({ id: 1, state: "active", issues: [ + iss("AURORA-1", { epic_key: "AURORA-10" }), iss("AURORA-2", { epic_key: "AURORA-10" }), + ] }), + sprint({ id: 2, state: "future", issues: [] }), + ], + backlog: [ + iss("AURORA-20", { epic_key: "AURORA-10" }), iss("AURORA-21", { epic_key: "AURORA-10" }), iss("AURORA-22", { epic_key: "AURORA-10" }), + iss("AURORA-23"), iss("AURORA-24"), iss("AURORA-25"), iss("AURORA-26"), iss("AURORA-27"), + ], + board_issues: null, ...over, + }); + + it("сценарий: строка эпика из бэклога, count 5 (3 бэклог + 2 активный спринт)", () => { + const d = data({ backlog: [epic, ...data().backlog!] }); + const e = epicEntries(d); + expect(e).toEqual([{ key: "AURORA-10", title: "Большой эпик", count: 5, done: false }]); + }); + + it("эпик выполнен — done true", () => { + const d = data({ backlog: [{ ...epic, done: true }] }); + expect(epicEntries(d)[0]).toMatchObject({ key: "AURORA-10", done: true }); + }); + + it("строки эпиков из спринтов и бэклога — без повторов по key", () => { + const d = data({ + sprints: [sprint({ id: 1, state: "active", issues: [epic] })], + backlog: [epic], + }); + expect(epicEntries(d).map((e) => e.key)).toEqual(["AURORA-10"]); + }); + + it("эпик, на который ссылаются, но строки нет — в конце, title = ключ, done = false", () => { + const d = data({ backlog: [epic, iss("AURORA-40", { epic_key: "AURORA-99" })] }); + const e = epicEntries(d); + expect(e.map((x) => x.key)).toEqual(["AURORA-10", "AURORA-99"]); + expect(e[1]).toEqual({ key: "AURORA-99", title: "AURORA-99", count: 1, done: false }); + }); + + it("задачи прошлых спринтов не считаются в count; эпики в count не входят", () => { + const d = data({ + sprints: [ + sprint({ id: 1, state: "active", issues: [iss("AURORA-1", { epic_key: "AURORA-10" })] }), + sprint({ id: 9, state: "closed", issues: [iss("AURORA-90", { epic_key: "AURORA-10" }), iss("AURORA-91", { epic_key: "AURORA-10" })] }), + ], + backlog: [epic], + }); + expect(epicEntries(d)).toEqual([{ key: "AURORA-10", title: "Большой эпик", count: 1, done: false }]); + }); + + it("задача в двух активных/будущих местах считается один раз", () => { + const shared = iss("AURORA-1", { epic_key: "AURORA-10" }); + const d = data({ + sprints: [sprint({ id: 1, state: "active", issues: [shared] }), sprint({ id: 2, state: "future", issues: [shared] })], + backlog: [epic, shared], + }); + expect(epicEntries(d)[0].count).toBe(1); + }); + + it("пустые данные — пустой список", () => { + expect(epicEntries({ board: null, sprints: [], backlog: null, board_issues: null })).toEqual([]); + }); +}); + +describe("byRank", () => { + it("по rank строкой, null в конце, затем по key", () => { + const list = [iss("B-1", { rank: null }), iss("B-3", { rank: "0|b" }), iss("B-2", { rank: "0|a" }), iss("B-0", { rank: null })]; + expect(list.sort(byRank).map((i) => i.key)).toEqual(["B-2", "B-3", "B-0", "B-1"]); + }); +}); + +describe("sprintIssues", () => { + it("задача в двух спринтах — одна запись; rank null в конце", () => { + const moved = iss("AURORA-1", { rank: "0|a" }); + const res = sprintIssues([ + sprint({ id: 1, state: "active", issues: [moved, iss("AURORA-2", { rank: null })] }), + sprint({ id: 2, state: "future", issues: [moved, iss("AURORA-3", { rank: "0|b" })] }), + ]); + expect(res.map((i) => i.key)).toEqual(["AURORA-1", "AURORA-3", "AURORA-2"]); + }); + it("пустой список спринтов — пусто", () => { + expect(sprintIssues([])).toEqual([]); + }); +}); + +describe("selectableSprints", () => { + it("закрытые отброшены; активные в порядке входа, затем будущие по start_date; без даты — последним", () => { + const res = selectableSprints([ + sprint({ id: 1, state: "closed" }), + sprint({ id: 2, state: "future", start_date: null }), + sprint({ id: 3, state: "future", start_date: "2026-11-05T09:00:00.000+0300" }), + sprint({ id: 4, state: "active" }), + sprint({ id: 5, state: "future", start_date: "2026-11-01T09:00:00.000+0300" }), + ]); + expect(res.map((s) => s.id)).toEqual([4, 5, 3, 2]); + }); +}); + +describe("parseSprintIds", () => { + const sprints = [ + sprint({ id: 101, state: "closed" }), + sprint({ id: 102, state: "active" }), + sprint({ id: 103, state: "future" }), + ]; + it("id из строки, только среди selectableSprints", () => { + expect(parseSprintIds("102,999", sprints)).toEqual([102]); + expect(parseSprintIds("101", sprints)).toEqual([]); + }); + it("порядок — как в selectableSprints, не как в строке", () => { + expect(parseSprintIds("103,102", sprints)).toEqual([102, 103]); + }); + it("«abc», null и пустая строка — все активные", () => { + expect(parseSprintIds("abc", sprints)).toEqual([102]); + expect(parseSprintIds(null, sprints)).toEqual([102]); + expect(parseSprintIds("", sprints)).toEqual([102]); + }); + it("будущий спринт по явному id", () => { + expect(parseSprintIds("103", sprints)).toEqual([103]); + }); +}); diff --git a/web/src/modules/jira/agile/filters.ts b/web/src/modules/jira/agile/filters.ts new file mode 100644 index 0000000..74af4a8 --- /dev/null +++ b/web/src/modules/jira/agile/filters.ts @@ -0,0 +1,103 @@ +// модель фильтров экранов доски (Спринт, Бэклог, Kanban): чистые функции без React и хранилища +import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts"; +import { isEpic, jiraDate } from "./model.ts"; + +export type AgilePage = "backlog" | "sprint" | "kanban"; + +// сохраняемый вид страницы (хранилище — agilePref.ts); поиск q и выбор спринтов сюда не входят +export interface AgileView { + assignee: string; epic: string; hideDone: boolean; collapsed: string[]; hiddenColumns: string[]; epicsPanel: boolean; +} +export const DEFAULT_VIEW: AgileView = { assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true }; + +// значение «Без исполнителя» / «Без эпика» +export const NONE = "-"; + +export interface IssueFilter { q: string; assignee: string; epic: string; hideDone: boolean } + +// И всех условий; q — подстрока без учёта регистра в key или summary +export function matches(i: AgileIssue, f: IssueFilter): boolean { + const q = f.q.trim().toLowerCase(); + if (q && !i.key.toLowerCase().includes(q) && !(i.summary ?? "").toLowerCase().includes(q)) return false; + if (f.assignee === NONE ? i.assignee !== null : f.assignee !== "" && i.assignee !== f.assignee) return false; + if (f.epic === NONE ? i.epic_key !== null : f.epic !== "" && i.epic_key !== f.epic) return false; + if (f.hideDone && i.done) return false; + return true; +} + +export function hasFilters(f: IssueFilter): boolean { + return f.q.trim() !== "" || f.assignee !== "" || f.epic !== "" || f.hideDone; +} + +export type Opt = { value: string; label: string }; + +// «все», «Без исполнителя», затем исполнители по алфавиту; current, которого нет в данных, — в конце (значение не теряется) +export function assigneeOptions(issues: AgileIssue[], current: string): Opt[] { + const names = [...new Set(issues.map((i) => i.assignee).filter((a): a is string => !!a))] + .sort((a, b) => a.localeCompare(b, "ru")); + const opts: Opt[] = [ + { value: "", label: "все" }, + { value: NONE, label: "Без исполнителя" }, + ...names.map((n) => ({ value: n, label: n })), + ]; + if (current && !opts.some((o) => o.value === current)) opts.push({ value: current, label: current }); + return opts; +} + +export interface EpicEntry { key: string; title: string; count: number; done: boolean } + +// порядок по rank (null в конце), затем по key +export function byRank(a: AgileIssue, b: AgileIssue): number { + if (a.rank === null && b.rank !== null) return 1; + if (a.rank !== null && b.rank === null) return -1; + if (a.rank !== null && b.rank !== null && a.rank !== b.rank) return a.rank < b.rank ? -1 : 1; + return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; +} + +// первое вхождение каждого key +const uniqByKey = (list: AgileIssue[]): AgileIssue[] => { + const seen = new Set(); + return list.filter((i) => { + if (seen.has(i.key)) return false; + seen.add(i.key); + return true; + }); +}; + +// объединение задач спринтов без повторов по key, по rank +export function sprintIssues(sprints: AgileSprint[]): AgileIssue[] { + return uniqByKey(sprints.flatMap((s) => s.issues)).sort(byRank); +} + +// активные (в порядке входа), затем будущие по дате начала; без даты — в конце; закрытые не выбираются +export function selectableSprints(sprints: AgileSprint[]): AgileSprint[] { + const active = sprints.filter((s) => s.state === "active"); + const future = sprints.filter((s) => s.state === "future"); + const startMs = (s: AgileSprint) => jiraDate(s.start_date)?.getTime() ?? Infinity; + return [...active, ...future.sort((a, b) => startMs(a) - startMs(b))]; +} + +// id из «101,102» среди selectableSprints, в их порядке; пусто или без годных чисел — все активные +export function parseSprintIds(param: string | null, sprints: AgileSprint[]): number[] { + const selectable = selectableSprints(sprints); + const ids = new Set( + (param ?? "").split(",").map((x) => x.trim()).filter((x) => x !== "").map(Number).filter((n) => Number.isInteger(n)), + ); + if (ids.size === 0) return selectable.filter((s) => s.state === "active").map((s) => s.id); + return selectable.filter((s) => ids.has(s.id)).map((s) => s.id); +} + +// строки-эпики из всех спринтов и бэклога (по rank, без повторов), затем ключи из epic_key без строки (по ключу); +// count — задачи-не-эпики с этим epic_key в активных, будущих спринтах и бэклоге, без повторов по key +export function epicEntries(d: AgileResponse): EpicEntry[] { + const backlog = d.backlog ?? []; + const live = [...d.sprints.filter((s) => s.state !== "closed").flatMap((s) => s.issues), ...backlog]; + const liveTasks = uniqByKey(live.filter((i) => !isEpic(i))); + const rows = uniqByKey([...d.sprints.flatMap((s) => s.issues), ...backlog].filter(isEpic)).sort(byRank); + const count = (key: string) => liveTasks.filter((t) => t.epic_key === key).length; + + const entries: EpicEntry[] = rows.map((e) => ({ key: e.key, title: e.summary ?? e.key, count: count(e.key), done: e.done })); + const known = new Set(rows.map((e) => e.key)); + const missing = [...new Set(liveTasks.map((t) => t.epic_key).filter((k): k is string => !!k && !known.has(k)))].sort(); + return [...entries, ...missing.map((key) => ({ key, title: key, count: count(key), done: false }))]; +}