Skip to content

fix(roadmap): UX backlog — легенда, touch, collision, kbd-nav, skeleton, a11y#160

Merged
NovakPAai merged 8 commits into
mainfrom
fix/roadmap-ux-backlog
May 10, 2026
Merged

fix(roadmap): UX backlog — легенда, touch, collision, kbd-nav, skeleton, a11y#160
NovakPAai merged 8 commits into
mainfrom
fix/roadmap-ux-backlog

Conversation

@NovakPAai
Copy link
Copy Markdown
Owner

Summary

Закрывает все открытые задачи из backlog-спеки roadmap-milestone.md (U1–U9, C1–C4, S1) и добавляет BDD + SDD-документацию.

Frontend (RoadmapView.tsx)

ID Фича / фикс
U1 Легенда (?-кнопка): popover с 5 строками (бар, milestone-ромб, сегментный бар, просрочка, off-screen badge) + kbd shortcuts
U2 Touch tooltip: onTouchStart + 4 с auto-hide + touchmove close; touchClearRef для корректного cleanup без утечки listener
U3 Scroll affordance: симметричные left + right fade-градиенты
U4 Collision detection: pairwise cluster sweep, равномерные ±7 px vertical offsets для N milestone в одной неделе
U6 Keyboard navigation: role="button", tabIndex={0}, aria-expanded, aria-label на строках с подзадачами
U7 Keyboard shortcuts W/M/Q zoom + Escape legend + guard для input/contenteditable/meta-keys
U8 Loading skeleton: pulse-анимация в левой панели и области баров
U9 Overdue off-screen badge: 4 px красная полоска слева + hover/touch tooltip + role="img" + aria-label
C1 today = getToday() вычисляется один раз перед рендером и передаётся в BarTooltip пропом
C2 onMouseMove → rAF throttle — нет 60fps ре-рендера
C3 Milestone без startDate → ромб (задокументировано)
C4 isMilestone вынесен как const в BarTooltip
S1 task.title в title-атрибуте ограничен 200 символами

Backend (boards.service.ts)

  • parseIsoDate(): strict ISO regex + isNaN guard → 400 на невалидные даты
  • toDate < fromDate → 400 (single-day range разрешён)
  • MAX_RANGE_DAYS = 730 guard (был: negative bypass при to < from)

Specs

  • specs/roadmap-ux-backlog.feature — Gherkin BDD, 30 сценариев по 10 областям
  • specs/roadmap-ux-backlog-sdd.md — SDD, 8 разделов с TypeScript-сниппетами, state-таблицей, a11y-маппингом, edge cases и тест-планом

Прошедшие ревью (перед PR)

Три параллельных ревью: code-reviewer, security-reviewer, UX/a11y reviewer — все HIGH и MEDIUM замечания устранены в коммите 44cdf1d.

Устранённые HIGH:

  • touchClearRef — убрана утечка touchmove-listener при быстрых повторных тапах
  • Collision cluster: pairwise сравнение (был баг для 3+ milestone с шагом > 22 px)
  • outline:none + :focus-visible CSS — надёжный focus-индикатор без imperative DOM-мутации
  • role="button" + вложенный <button>: chevron получил tabIndex={-1} + aria-hidden
  • Skeleton: role="status" + aria-busy + aria-label

Устранённые MEDIUM (ключевые):

  • SAFE_COLOR_RE: \s*[ \t]* — запрет newline в CSS-функциях
  • BarTooltip получает today пропом (устранена независимая getToday())
  • kbd guard: e.metaKey || e.ctrlKey || e.altKey
  • Quarter branch: const t = getToday() один раз вместо 9 вызовов в цикле
  • milestoneYOffsets завёрнут в useMemo([rows, dayPx])
  • aria-haspopup="dialog" (не "true")
  • prefers-reduced-motion: добавлен transition:none!important
  • aria-live="polite" span — анонс смены масштаба для screen reader
  • Overdue badge: role="img" + aria-label

Test plan

  • Задача только с dueDate → ромб в позиции дедлайна (регрессия предыдущего PR)
  • Hover на ромб → tooltip; hover на бар → tooltip; tooltip правильно позиционируется у края экрана
  • Тап на ромб → tooltip; 4 с → автоскрытие; скролл пальцем → мгновенное скрытие
  • 3 задачи с одинаковым dueDate → 3 ромба со смещением, не перекрываются
  • Нажать W → масштаб «Неделя»; M → «Месяц»; Q → «Квартал»; Escape → легенда закрыта
  • Tab по строкам с подзадачами; Enter/Space разворачивает; focus ring виден
  • Кнопка ? → легенда; click outside → закрывается; Escape → закрывается
  • Задача с dueDate до range.start → красная полоска слева + tooltip при hover
  • При загрузке — skeleton в обеих панелях; после загрузки — исчезает
  • prefers-reduced-motion: reduce → нет анимаций и переходов
  • Светлая + тёмная тема — все элементы читаемы

🤖 Generated with Claude Code

Задачи без startDate получали start = end = dueDate → ширина бара 0 → Math.max(6, 0) = 6px → вертикальная черта вместо нормального элемента.

Решение: задачи с только dueDate (без startDate) теперь рендерятся как ромб-маркер (milestone diamond) — стандарт для дедлайнов в Gantt-диаграммах. Ромб поддерживает hover-тултип, анимацию просрочки и работает на всех уровнях зума (week/month/quarter). Тултип обновлён: показывает «Дедлайн: X» вместо диапазона дат для milestone-задач.
Frontend (RoadmapView.tsx):
- fix: разделить positioning и scale transforms (HIGH code) — wrapper держит
  translateY(-50%), inner div держит rotate/scale → hover не ломает позицию
- fix: child-ромб минимум 14px вместо 12 (HIGH ux)
- fix: hitbox ромба 32px вместо 16 — легче попасть мышью (HIGH ux)
- fix: clampedMx — ромб не вылезает за левый/правый край (CRITICAL ux)
- fix: иконка в тултипе milestone — ромб SVG вместо галочки (HIGH ux)
- fix: hint «Добавьте дату начала» в тултипе milestone (MEDIUM ux)
- fix: «нет дат» перенесено на left:10, было right:10 (LOW ux)
- fix: safeColor() — валидация status.color перед inline CSS (MEDIUM security)
- fix: prefers-reduced-motion @media в <style> (MEDIUM ux)

Backend (boards.service.ts):
- fix: statusHistory select — только нужные поля (LOW security)
- fix: MAX_RANGE_DAYS=730 — защита от слишком широкого диапазона дат (LOW security)

Specs:
- docs: specs/roadmap-milestone.md — полная спека с backlog открытых задач
…legend a11y, left fade, BDD+SDD спеки

- Backend: parseIsoDate() — 400 на невалидную дату; toDate <= fromDate — 400 до проверки MAX_RANGE_DAYS
- Frontend touch tooltip: touchmove close listener + 4000ms auto-hide + touchTimerRef (milestone, bar, badge)
- Legend: aria-haspopup="true"; добавить строки сегментного бара и off-screen badge
- Scroll affordance: симметричный left-fade (зеркально к right-fade)
- Overdue badge: onMouseEnter/onMouseLeave/onTouchStart → показывает full tooltip при наведении
- Устранить today-shadowing в ghost-tail IIFE (использовать outer today)
- Specs: roadmap-ux-backlog.feature (Gherkin, 30 сценариев) + roadmap-ux-backlog-sdd.md (SDD, 8 разделов)
Код-ревью:
- touchClearRef: removeEventListener перед каждым новым touchstart, + в cleanup useEffect и в timeout callback — устранена утечка {once:true} listener
- Collision cluster: pairwise сравнение pts[j].mx - pts[j-1].mx < 22 вместо якорного pts[i].mx — корректно для 3+ milestone
- milestoneYOffsets завёрнут в useMemo([rows, dayPx]) — не пересчитывать при hover
- today передаётся в BarTooltip пропом — убрана независимая getToday() в компоненте
- Keyboard guard: e.metaKey || e.ctrlKey || e.altKey — не перехватывать системные шоткаты
- quarter branch renderHeader: const t = getToday() один раз вместо 9 вызовов в цикле

ИБ-ревью:
- parseIsoDate: strict ISO regex /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/ перед new Date()
- toDate < fromDate (строго, single-day range допустим)
- SAFE_COLOR_RE: \s* → [ \t]* — запрет управляющих символов в функциональной нотации

UX/a11y:
- [role="button"]:focus-visible { outline: 2px solid #4F6EF7 } в style-блоке — надёжный focus-индикатор без imperative мутации
- prefers-reduced-motion: добавить transition:none!important
- Chevron: tabIndex={-1} + aria-hidden="true" — устранить двойную интерактивность
- Skeleton: role="status" + aria-label + aria-busy={loading}
- Legend button: aria-haspopup="dialog" (не "true")
- aria-live="polite" span: анонс смены масштаба для screen reader
- Overdue badge: role="img" + aria-label
Backend:
- parseIsoDate → module scope, generic error message (не отдаёт user input в ответе)
- MAX_ROADMAP_RANGE_DAYS — именованная константа
- logEvent .catch() в createBoard/updateBoard/deleteBoard — audit log не прерывает основную операцию
- updateBoard: явный список полей вместо dto spread → защита от mass-assignment
- getBoardByPrefix: убран /i флаг (валидируем после .toUpperCase())
- take:100 в getBoard прокомментирован

Frontend — код:
- parseDate: strict ISO regex перед new Date()
- historySegs: parseDate() для seg.startedAt/endedAt вместо голого new Date() — защита от NaN px
- window.innerWidth/Height: guard для SSR/embedded (fallback 1280/800)
- handleTouch: единый useCallback — убрана 30-строчная трипликация onTouchStart
- rowYOffsets useMemo → yOffset = rowYOffsets[idx] вместо O(n²) reduce
- legendPopoverRef: focus на popover при открытии (useEffect)
- assignee: имя ограничено 40 символами, fallback '?' при пустой строке
- isMilestone: используется константа в ветке рендера вместо inline !start && end
- historySegs key: `${seg.name}-${i}` вместо index
- legend rows key: row.label вместо index

Frontend — a11y:
- aria-atomic="true" на zoom live region
- role="group" + aria-label на zoom button group
- aria-pressed на zoom buttons и hide-open toggle
- aria-hidden="true" на Today SVG, eye SVGs, BarTooltip decorative SVGs, priority dot
- aria-live="polite" + aria-atomic на task count span
- Legend popup: role="dialog" + aria-modal + aria-labelledby="rm-legend-heading" + tabIndex=-1 + ref focus
- id="rm-legend-heading" на заголовок легенды
- aria-hidden на backdrop div
- role="status" + aria-label на skeleton, role="status" + aria-live на empty state
- role="presentation" на today line
- Milestone diamond: tabIndex=0, role="button", aria-label, onFocus/onBlur tooltip
- Overdue tail: role="img" + aria-label
- data-roadmap-root: prefers-reduced-motion scoped к компоненту (не глобально)
- button:focus-visible{ outline } в style block
# Conflicts:
#	frontend/src/components/RoadmapView.tsx
@NovakPAai NovakPAai merged commit 7c8eece into main May 10, 2026
8 checks passed
@NovakPAai NovakPAai deleted the fix/roadmap-ux-backlog branch May 10, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants