diff --git a/web/src/board/__fixtures__/hub-basic.expected.json b/web/src/board/__fixtures__/hub-basic.expected.json
index 4c33bc1..f59f2fa 100644
--- a/web/src/board/__fixtures__/hub-basic.expected.json
+++ b/web/src/board/__fixtures__/hub-basic.expected.json
@@ -6312,6 +6312,788 @@
}
}
},
+ "saved-short": {
+ "emptyReason": null,
+ "layout": {
+ "baseW": 1208,
+ "board": {
+ "height": "1124px",
+ "width": "1208px"
+ },
+ "clamp": {
+ "AURORA-9001": "2",
+ "AURORA-9002": "3",
+ "AURORA-9003": "unset",
+ "AURORA-9004": "6",
+ "AURORA-9005": "unset",
+ "AURORA-9006": "unset",
+ "AURORA-9007": "unset",
+ "AURORA-9008": "12",
+ "AURORA-9009": "unset"
+ },
+ "colBounds": {},
+ "dateCols": [],
+ "dateLabels": [
+ {
+ "className": "dlabel none",
+ "innerHTML": "без срока1",
+ "left": "20px",
+ "title": "плановая дата не задана"
+ },
+ {
+ "className": "dlabel past",
+ "innerHTML": "10.101",
+ "left": "316px",
+ "title": "срок прошёл"
+ },
+ {
+ "className": "dlabel past",
+ "innerHTML": "12.101",
+ "left": "612px",
+ "title": "срок прошёл"
+ },
+ {
+ "className": "dlabel",
+ "innerHTML": "16.101",
+ "left": "908px",
+ "title": "плановая дата окончания"
+ },
+ {
+ "className": "dlabel",
+ "innerHTML": "18.101",
+ "left": "1204px",
+ "title": "плановая дата окончания"
+ },
+ {
+ "className": "dlabel",
+ "innerHTML": "20.103",
+ "left": "1500px",
+ "title": "плановая дата окончания"
+ },
+ {
+ "className": "dlabel",
+ "innerHTML": "25.101",
+ "left": "1900px",
+ "title": "плановая дата окончания"
+ }
+ ],
+ "edges": [
+ {
+ "crit": false,
+ "d": "M270 372 L291 372 Q295 372 295 376 L295 376 Q295 380 299 380 L320 380",
+ "dst": "AURORA-9002",
+ "hand": true,
+ "src": "AURORA-9001",
+ "type": "блокирует начало"
+ },
+ {
+ "crit": false,
+ "d": "M264 498 L288 498 Q298 498 298 488 L298 382 Q298 372 288 372 L270 372",
+ "dst": "AURORA-9001",
+ "hand": true,
+ "src": "AURORA-9003",
+ "type": "блокирует начало"
+ },
+ {
+ "crit": false,
+ "d": "M560 380 L726 380 Q736 380 736 370 L736 110 Q736 100 746 100 L912 100",
+ "dst": "AURORA-9005",
+ "hand": false,
+ "src": "AURORA-9002",
+ "type": "блокирует завершение"
+ },
+ {
+ "crit": false,
+ "d": "M270 150 L285 150 Q295 150 295 160 L295 370 Q295 380 305 380 L320 380",
+ "dst": "AURORA-9002",
+ "hand": true,
+ "src": "AURORA-9004",
+ "type": "блокирует начало"
+ },
+ {
+ "crit": false,
+ "d": "M150 432 L150 516 Q150 526 160 526 L160 526 Q170 526 170 536 L170 620",
+ "dst": "AURORA-9008",
+ "hand": false,
+ "src": "AURORA-9001",
+ "type": "связанные"
+ },
+ {
+ "crit": false,
+ "d": "M264 362 L320 362",
+ "dst": "AURORA-9009",
+ "hand": true,
+ "src": "AURORA-9007",
+ "type": "блокирует начало"
+ }
+ ],
+ "laneOf": {
+ "AURORA-9001": 1,
+ "AURORA-9002": 1,
+ "AURORA-9003": 1,
+ "AURORA-9004": 0,
+ "AURORA-9005": 0,
+ "AURORA-9006": 3,
+ "AURORA-9007": 1,
+ "AURORA-9008": 2,
+ "AURORA-9009": 1
+ },
+ "lanes": [
+ {
+ "folded": false,
+ "h": 262,
+ "minH": 144,
+ "name": "Гудвилл",
+ "y": 0
+ },
+ {
+ "folded": false,
+ "h": 308,
+ "minH": 144,
+ "name": "Тестовый эпик",
+ "y": 262
+ },
+ {
+ "folded": false,
+ "h": 382,
+ "minH": 144,
+ "name": "AURORA-9999",
+ "y": 570
+ },
+ {
+ "folded": false,
+ "h": 172,
+ "minH": 144,
+ "name": "Без эпика",
+ "y": 952
+ }
+ ],
+ "pos": {
+ "AURORA-9001": {
+ "h": 120,
+ "w": 240,
+ "x": 30,
+ "y": 312
+ },
+ "AURORA-9002": {
+ "h": 136,
+ "w": 240,
+ "x": 320,
+ "y": 312
+ },
+ "AURORA-9003": {
+ "h": 120,
+ "w": 240,
+ "x": 24,
+ "y": 438
+ },
+ "AURORA-9004": {
+ "h": 200,
+ "w": 240,
+ "x": 30,
+ "y": 50
+ },
+ "AURORA-9005": {
+ "h": 120,
+ "w": 240,
+ "x": 912,
+ "y": 40
+ },
+ "AURORA-9006": {
+ "h": 120,
+ "w": 240,
+ "x": 24,
+ "y": 992
+ },
+ "AURORA-9007": {
+ "h": 120,
+ "w": 240,
+ "x": 24,
+ "y": 302
+ },
+ "AURORA-9008": {
+ "h": 320,
+ "w": 280,
+ "x": 30,
+ "y": 620
+ },
+ "AURORA-9009": {
+ "h": 120,
+ "w": 240,
+ "x": 320,
+ "y": 302
+ }
+ },
+ "rel": {
+ "AURORA-9001": {
+ "auto": false,
+ "h": 120,
+ "other": 79,
+ "ry": 50,
+ "w": 240,
+ "x": 30
+ },
+ "AURORA-9002": {
+ "auto": false,
+ "h": 136,
+ "other": 79,
+ "ry": 50,
+ "w": 240,
+ "x": 320
+ },
+ "AURORA-9003": {
+ "auto": true,
+ "h": 120,
+ "other": 79,
+ "ry": 176,
+ "w": 240,
+ "x": 24
+ },
+ "AURORA-9004": {
+ "auto": false,
+ "h": 200,
+ "other": 79,
+ "ry": 50,
+ "w": 240,
+ "x": 30
+ },
+ "AURORA-9005": {
+ "auto": true,
+ "h": 120,
+ "other": 79,
+ "ry": 40,
+ "w": 240,
+ "x": 912
+ },
+ "AURORA-9006": {
+ "auto": true,
+ "h": 120,
+ "other": 79,
+ "ry": 40,
+ "w": 240,
+ "x": 24
+ },
+ "AURORA-9007": {
+ "auto": true,
+ "h": 120,
+ "other": 79,
+ "ry": 40,
+ "w": 240,
+ "x": 24
+ },
+ "AURORA-9008": {
+ "auto": false,
+ "h": 320,
+ "other": 79,
+ "ry": 50,
+ "w": 280,
+ "x": 30
+ },
+ "AURORA-9009": {
+ "auto": true,
+ "h": 120,
+ "other": 79,
+ "ry": 40,
+ "w": 240,
+ "x": 320
+ }
+ }
+ },
+ "visible": {
+ "lanes": [
+ {
+ "keys": [
+ "AURORA-9004",
+ "AURORA-9005"
+ ],
+ "name": "Гудвилл"
+ },
+ {
+ "keys": [
+ "AURORA-9001",
+ "AURORA-9002",
+ "AURORA-9003",
+ "AURORA-9007",
+ "AURORA-9009"
+ ],
+ "name": "Тестовый эпик"
+ },
+ {
+ "keys": [
+ "AURORA-9008"
+ ],
+ "name": "AURORA-9999"
+ },
+ {
+ "keys": [
+ "AURORA-9006"
+ ],
+ "name": "Без эпика"
+ }
+ ],
+ "links": [
+ {
+ "crit": false,
+ "dst": "AURORA-9002",
+ "hand": true,
+ "src": "AURORA-9001",
+ "type": "блокирует начало"
+ },
+ {
+ "crit": false,
+ "dst": "AURORA-9001",
+ "hand": true,
+ "src": "AURORA-9003",
+ "type": "блокирует начало"
+ },
+ {
+ "crit": false,
+ "dst": "AURORA-9005",
+ "hand": false,
+ "src": "AURORA-9002",
+ "type": "блокирует завершение"
+ },
+ {
+ "crit": false,
+ "dst": "AURORA-9002",
+ "hand": true,
+ "src": "AURORA-9004",
+ "type": "блокирует начало"
+ },
+ {
+ "crit": false,
+ "dst": "AURORA-9008",
+ "hand": false,
+ "src": "AURORA-9001",
+ "type": "связанные"
+ },
+ {
+ "crit": false,
+ "dst": "AURORA-9009",
+ "hand": true,
+ "src": "AURORA-9007",
+ "type": "блокирует начало"
+ }
+ ],
+ "nodes": [
+ {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9001",
+ "kind": "разработка",
+ "prio": "Высокий",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "В работе",
+ "stub": false,
+ "title": "Схема свода",
+ "who": "Иванов Иван Иванович"
+ },
+ {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9002",
+ "kind": "разработка",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Ожидание разработки",
+ "stub": false,
+ "title": "API свода",
+ "who": "Петров Пётр Петрович"
+ },
+ {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-16",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9003",
+ "kind": "UI",
+ "prio": "Критичный",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Backlog",
+ "stub": false,
+ "title": "Макет экрана",
+ "who": ""
+ },
+ {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-25",
+ "epic": "Гудвилл",
+ "focus": false,
+ "key": "AURORA-9004",
+ "kind": "решение",
+ "prio": "Наивысший",
+ "sprint": "AWPCA_Спринт 3",
+ "status": "К выполнению",
+ "stub": false,
+ "title": "Решение по порогам",
+ "who": "Сидорова Анна"
+ },
+ {
+ "crit": false,
+ "dim": false,
+ "due": "",
+ "epic": "Гудвилл",
+ "focus": false,
+ "key": "AURORA-9005",
+ "kind": "тестирование",
+ "prio": "Низкий",
+ "sprint": "AWPCA_Спринт 3",
+ "status": "Ожидание",
+ "stub": false,
+ "title": "Тест-кейсы",
+ "who": "Петров Пётр Петрович"
+ },
+ {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-10",
+ "epic": "Без эпика",
+ "focus": false,
+ "key": "AURORA-9006",
+ "kind": "Задача",
+ "prio": "Средний",
+ "sprint": "",
+ "status": "Готово",
+ "stub": false,
+ "title": "Задача без эпика",
+ "who": "Иванов Иван Иванович"
+ },
+ {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-12",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9007",
+ "kind": "аналитика",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 1",
+ "status": "В работе",
+ "stub": false,
+ "title": "Сосед по связям",
+ "who": "Kozlov-AA"
+ },
+ {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-18",
+ "epic": "AURORA-9999",
+ "focus": false,
+ "key": "AURORA-9008",
+ "kind": "актуализация",
+ "prio": "Самый низкий",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Ожидание разработки",
+ "stub": false,
+ "title": "Актуализация ТЗ",
+ "who": "Иванов Иван Иванович"
+ },
+ {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9009",
+ "kind": "организационная",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "",
+ "stub": false,
+ "title": "Созвон с заказчиком",
+ "who": "Иванов Иван Иванович"
+ }
+ ],
+ "problems": {
+ "dates": [
+ {
+ "a": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9001",
+ "kind": "разработка",
+ "prio": "Высокий",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "В работе",
+ "stub": false,
+ "title": "Схема свода",
+ "who": "Иванов Иван Иванович"
+ },
+ "b": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9002",
+ "kind": "разработка",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Ожидание разработки",
+ "stub": false,
+ "title": "API свода",
+ "who": "Петров Пётр Петрович"
+ },
+ "text": "сроки совпадают (20.10): запаса нет"
+ },
+ {
+ "a": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-25",
+ "epic": "Гудвилл",
+ "focus": false,
+ "key": "AURORA-9004",
+ "kind": "решение",
+ "prio": "Наивысший",
+ "sprint": "AWPCA_Спринт 3",
+ "status": "К выполнению",
+ "stub": false,
+ "title": "Решение по порогам",
+ "who": "Сидорова Анна"
+ },
+ "b": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9002",
+ "kind": "разработка",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Ожидание разработки",
+ "stub": false,
+ "title": "API свода",
+ "who": "Петров Пётр Петрович"
+ },
+ "text": "блокер должен закончиться 25.10, а зависимая — уже 20.10"
+ }
+ ],
+ "hand": [
+ {
+ "a": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9001",
+ "kind": "разработка",
+ "prio": "Высокий",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "В работе",
+ "stub": false,
+ "title": "Схема свода",
+ "who": "Иванов Иван Иванович"
+ },
+ "b": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9002",
+ "kind": "разработка",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Ожидание разработки",
+ "stub": false,
+ "title": "API свода",
+ "who": "Петров Пётр Петрович"
+ },
+ "text": "Петров П. П. ждёт Иванов И. И."
+ },
+ {
+ "a": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-25",
+ "epic": "Гудвилл",
+ "focus": false,
+ "key": "AURORA-9004",
+ "kind": "решение",
+ "prio": "Наивысший",
+ "sprint": "AWPCA_Спринт 3",
+ "status": "К выполнению",
+ "stub": false,
+ "title": "Решение по порогам",
+ "who": "Сидорова Анна"
+ },
+ "b": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9002",
+ "kind": "разработка",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Ожидание разработки",
+ "stub": false,
+ "title": "API свода",
+ "who": "Петров Пётр Петрович"
+ },
+ "text": "Петров П. П. ждёт Сидорова А."
+ },
+ {
+ "a": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-12",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9007",
+ "kind": "аналитика",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 1",
+ "status": "В работе",
+ "stub": false,
+ "title": "Сосед по связям",
+ "who": "Kozlov-AA"
+ },
+ "b": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9009",
+ "kind": "организационная",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "",
+ "stub": false,
+ "title": "Созвон с заказчиком",
+ "who": "Иванов Иван Иванович"
+ },
+ "text": "Иванов И. И. ждёт Kozlov-AA"
+ }
+ ],
+ "late": [
+ {
+ "a": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-16",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9003",
+ "kind": "UI",
+ "prio": "Критичный",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Backlog",
+ "stub": false,
+ "title": "Макет экрана",
+ "who": ""
+ },
+ "b": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9001",
+ "kind": "разработка",
+ "prio": "Высокий",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "В работе",
+ "stub": false,
+ "title": "Схема свода",
+ "who": "Иванов Иван Иванович"
+ },
+ "text": "блокер в статусе «Backlog», а до срока зависимой 6 дн."
+ },
+ {
+ "a": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-25",
+ "epic": "Гудвилл",
+ "focus": false,
+ "key": "AURORA-9004",
+ "kind": "решение",
+ "prio": "Наивысший",
+ "sprint": "AWPCA_Спринт 3",
+ "status": "К выполнению",
+ "stub": false,
+ "title": "Решение по порогам",
+ "who": "Сидорова Анна"
+ },
+ "b": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9002",
+ "kind": "разработка",
+ "prio": "Средний",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Ожидание разработки",
+ "stub": false,
+ "title": "API свода",
+ "who": "Петров Пётр Петрович"
+ },
+ "text": "блокер в статусе «К выполнению», а до срока зависимой 6 дн."
+ }
+ ],
+ "unassigned": [
+ {
+ "a": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-16",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9003",
+ "kind": "UI",
+ "prio": "Критичный",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "Backlog",
+ "stub": false,
+ "title": "Макет экрана",
+ "who": ""
+ },
+ "b": {
+ "crit": false,
+ "dim": false,
+ "due": "2026-10-20",
+ "epic": "Тестовый эпик",
+ "focus": false,
+ "key": "AURORA-9001",
+ "kind": "разработка",
+ "prio": "Высокий",
+ "sprint": "AWPCA_Спринт 2",
+ "status": "В работе",
+ "stub": false,
+ "title": "Схема свода",
+ "who": "Иванов Иван Иванович"
+ },
+ "text": "Иванов И. И. ждёт задачу без исполнителя"
+ }
+ ]
+ },
+ "removed": {
+ "core": 9,
+ "ctx": 0,
+ "edge": 0,
+ "loose": 0
+ }
+ }
+ },
"sprint-2": {
"emptyReason": null,
"layout": {
diff --git a/web/src/board/__fixtures__/scenarios.json b/web/src/board/__fixtures__/scenarios.json
index 1390c4f..e225c00 100644
--- a/web/src/board/__fixtures__/scenarios.json
+++ b/web/src/board/__fixtures__/scenarios.json
@@ -10,5 +10,6 @@
{"name": "lane-order", "filters": {}, "laneOrder": ["Гудвилл", "Без эпика", "Тестовый эпик"]},
{"name": "by-date", "filters": {"byDate": true}},
{"name": "saved-folded", "filters": {}, "saved": {"AURORA-9002": {"x": 600, "ry": 80, "w": 280, "h": 160}, "AURORA-9003": {"x": 30, "ry": 50}}, "folded": ["Гудвилл"]},
- {"name": "by-date-colw", "filters": {"byDate": true}, "colWidths": {"2026-10-20": 400}, "savedDate": {"AURORA-9001": {"x": 10, "ry": 60, "w": 250}}}
+ {"name": "by-date-colw", "filters": {"byDate": true}, "colWidths": {"2026-10-20": 400}, "savedDate": {"AURORA-9001": {"x": 10, "ry": 60, "w": 250}}},
+ {"name": "saved-short", "filters": {}, "saved": {"AURORA-9001": {"x": 30, "ry": 50, "w": 240, "h": 120}, "AURORA-9002": {"x": 320, "ry": 50, "w": 240, "h": 136}, "AURORA-9004": {"x": 30, "ry": 50, "w": 240, "h": 200}, "AURORA-9008": {"x": 30, "ry": 50, "w": 280, "h": 320}}}
]
diff --git a/web/src/board/analysis/facets.ts b/web/src/board/analysis/facets.ts
index 0840e52..c432857 100644
--- a/web/src/board/analysis/facets.ts
+++ b/web/src/board/analysis/facets.ts
@@ -56,7 +56,9 @@ export function initialSprints(model: BoardModel, f: Filters, remembered: unknow
}
// список исполнителей: чистый фильтр, на доске только их задачи; соседей добавляет кнопка «+ соседи по связям»
export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set {
- if (!model.nodes.length || whoBox(model, f).kind !== "multi") return new Set();
+ if (!model.nodes.length) return new Set();
const { total } = whoItems(model, f);
+ const names = Object.keys(total);
+ if (!names.length || (names.length === 1 && names[0] === "")) return new Set();
return new Set(strList(remembered).filter((w) => total[w] !== undefined));
}
diff --git a/web/src/board/model/fromJson.ts b/web/src/board/model/fromJson.ts
index 587565a..41fe289 100644
--- a/web/src/board/model/fromJson.ts
+++ b/web/src/board/model/fromJson.ts
@@ -1,7 +1,7 @@
import { CW, KEY_RE, NO_EPIC } from "../constants.ts";
+import { strList } from "../storage.ts";
import type { BoardNode, Dicts, JsonImport, Link, SavedBox } from "../types.ts";
import { addLink, addStubs } from "./links.ts";
-import { strList } from "../storage.ts";
import { sortableDate } from "./util.ts";
type Raw = Record; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы
diff --git a/web/src/board/model/util.test.ts b/web/src/board/model/util.test.ts
index fb88ec6..cc0f629 100644
--- a/web/src/board/model/util.test.ts
+++ b/web/src/board/model/util.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { dayDiff, fmtDate, plural, shortName, sortableDate } from "./util.ts";
+import { dayDiff, emptyModel, fmtDate, fmtStamp, notStarted, plural, shortName, sortableDate, unassigned, whoKey } from "./util.ts";
describe("util", () => {
it("sortableDate приводит даты Jira к ГГГГ-ММ-ДД", () => {
@@ -19,4 +19,17 @@ describe("util", () => {
expect(dayDiff("2026-10-14", "2026-10-20")).toBe(6);
expect(dayDiff("", "2026-10-20")).toBeNull();
});
+ it("notStarted, unassigned, whoKey, fmtStamp, emptyModel", () => {
+ expect(notStarted("Backlog")).toBe(true);
+ expect(notStarted("К выполнению")).toBe(true);
+ expect(notStarted("В работе")).toBe(false);
+ expect(unassigned("")).toBe(true);
+ expect(unassigned("Не назначен")).toBe(true);
+ expect(unassigned("Иванов")).toBe(false);
+ expect(whoKey(" Иванов ")).toBe("Иванов");
+ expect(whoKey("unassigned")).toBe("");
+ expect(fmtStamp("не дата")).toBe("не дата");
+ expect(fmtStamp("2026-10-14T09:05:00")).toMatch(/^14\.10 09:05$/);
+ expect(emptyModel("пусто")).toMatchObject({ nodes: [], links: [], emptyText: "пусто", missing: 0 });
+ });
});
diff --git a/web/src/board/storage.test.ts b/web/src/board/storage.test.ts
index fdd6184..0c926be 100644
--- a/web/src/board/storage.test.ts
+++ b/web/src/board/storage.test.ts
@@ -5,13 +5,14 @@ import type { BoardSnapshot } from "../api/types.ts";
import { canon } from "../test/canon.ts";
import { computeVisible } from "./analysis/visible.ts";
import { dictsFromSettings } from "./dicts.ts";
-import { exportJson } from "./exportJson.ts";
+import { exportFileName, exportJson } from "./exportJson.ts";
import settings from "./__fixtures__/settings.json";
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, migrateMode, migrateStore, readPrefs, saveLayout, savedLayout, setColWidth, writeFlag } from "./storage.ts";
+import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, toggleLane, writeFlag } from "./storage.ts";
+import type { BoardModel, JsonImport } from "./types.ts";
import { noFilters } from "./types.ts";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8");
@@ -80,3 +81,48 @@ describe("хранилище = links.html@3931a88", () => {
expect(savedLayout(false)).toBeNull();
});
});
+
+describe("операции хранилища", () => {
+ beforeEach(() => localStorage.clear());
+ it("colWidths пропускает только конечные числа (T9.1)", () => {
+ localStorage.setItem("graph-colw:board", JSON.stringify({ "2026-10-20": 400, "2026-10-21": "x", "2026-10-22": null }));
+ expect(colWidths()).toEqual({ "2026-10-20": 400 });
+ });
+ it("toggleLane сворачивает и разворачивает", () => {
+ toggleLane("Гудвилл");
+ expect(foldedSet()).toEqual(new Set(["Гудвилл"]));
+ toggleLane("Гудвилл");
+ expect(foldedSet()).toEqual(new Set());
+ });
+ it("moveLane переставляет и сохраняет имена вне текущей выборки", () => {
+ localStorage.setItem("graph-lanes:board", JSON.stringify(["A", "B", "C", "Скрытая"]));
+ expect(moveLane(["A", "B", "C"], "A", 2)).toBe(true);
+ expect(laneOrder()).toEqual(["B", "C", "A", "Скрытая"]);
+ expect(moveLane(["A", "B", "C"], "A", 5)).toBe(false);
+ expect(moveLane(["A", "B", "C"], "нет", 0)).toBe(false);
+ });
+ it("resetLayout: обычный режим не трогает «по датам», режим дат чистит и ширины колонок", () => {
+ for (const k of ["pos", "posdate", "lanes", "fold", "colw"]) localStorage.setItem(`graph-${k}:board`, "{}");
+ resetLayout(false);
+ expect(Object.keys(localStorage).sort()).toEqual(["graph-colw:board", "graph-posdate:board"]);
+ resetLayout(true);
+ expect(Object.keys(localStorage)).toEqual([]);
+ });
+ it("exportFileName: имя файла без .csv и недопустимых символов, дата из now", () => {
+ expect(exportFileName({ fileName: "Jira (1).csv" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-Jira_1_-2026-10-14.json");
+ expect(exportFileName({ fileName: "" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-board-2026-10-14.json");
+ });
+ it("applyJsonSettings: mode=all — пустой выбор спринтов", () => {
+ applyJsonSettings({ layout: {}, layoutByDate: {}, laneOrder: [], collapsedLanes: null, dateColumns: null,
+ filters: { mode: "all", sprints: ["S 2"] } } as unknown as JsonImport);
+ expect(localStorage.getItem("graph-sprint:board")).toBe("[]");
+ });
+ it("закрытое хранилище: чтение — значения по умолчанию, запись и удаление без исключений", () => {
+ const boom = () => { throw new DOMException("closed", "SecurityError"); };
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(boom);
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(boom);
+ vi.spyOn(Storage.prototype, "removeItem").mockImplementation(boom);
+ expect(laneOrder()).toEqual([]);
+ expect(() => { toggleLane("A"); resetLayout(true); setColWidth("2026-10-20", 400); }).not.toThrow();
+ });
+});
diff --git a/web/src/board/storage.ts b/web/src/board/storage.ts
index 8b5558d..fbd132d 100644
--- a/web/src/board/storage.ts
+++ b/web/src/board/storage.ts
@@ -48,7 +48,8 @@ export const laneOrder = (): string[] => strList(store.json(fileKey("lanes"), []
export const foldedSet = (): Set => new Set(strList(store.json(fileKey("fold"), [])));
export function colWidths(): Record {
const v = store.json(fileKey("colw"), {});
- return isObj(v) ? v as Record : {};
+ if (!isObj(v)) return {};
+ return Object.fromEntries(Object.entries(v).filter(([, w]) => num(w))) as Record; // T9.1
}
const layoutKey = (byDate: boolean): string => fileKey(byDate ? "posdate" : "pos"); // у обычной раскладки и раскладки «по датам» свои хранилища
diff --git a/web/src/test/canon.test.ts b/web/src/test/canon.test.ts
index 9558883..a6fd782 100644
--- a/web/src/test/canon.test.ts
+++ b/web/src/test/canon.test.ts
@@ -11,4 +11,7 @@ describe("canon", () => {
expect(canon(foreign)).toEqual(["a", "b"]);
expect(canon(new Set([{ k: 2 }, { k: 1 }]))).toEqual([{ k: 1 }, { k: 2 }]);
});
+ it("порядок Set не зависит от локали: сравнение по кодам символов", () => {
+ expect(canon(new Set(["б", "Б", "b", "B"]))).toEqual(["B", "b", "Б", "б"]);
+ });
});
diff --git a/web/src/test/canon.ts b/web/src/test/canon.ts
index f251c09..820c3c2 100644
--- a/web/src/test/canon.ts
+++ b/web/src/test/canon.ts
@@ -3,7 +3,7 @@
const isSet = (v: unknown): v is Set => Object.prototype.toString.call(v) === "[object Set]";
export function canon(v: unknown): unknown {
- if (isSet(v)) return [...v].map(canon).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
+ if (isSet(v)) return [...v].map(canon).sort((a, b) => { const x = JSON.stringify(a), y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; });
if (Array.isArray(v)) return v.map(canon);
if (v && typeof v === "object") {
const o = v as Record;