- listUserSessions: + isReplaced = false — иначе каждый рефреш токена добавлял
в список устройств «новую сессию» (цепочка ротации видна только для детекта кражи);
- rotateFamilySession: userAgent/ipAddress переносятся из исходной сессии —
rotated-строки больше не «Неизвестное устройство»;
- новый worker session-cleanup (тик раз в сутки + прогон при старте): удаляет
replaced-сессии старше 72ч (grace-окно детекта кражи) и истёкшие.
useCallback для ChatSection стоял после раннего return (if (!task)) —
в рендерах без задачи хук не вызывался → «Rendered more hooks than during
the previous render». Правильное решение: компонент на module level с пропом
taskId (стабильный тип + нет зависимости от хуков).
- TaskDetail: ChatSection объявлен inline в теле рендера → новый тип компонента
на каждый рендер → React перемонтировал весь чат при ЛЮБОМ ре-рендере TaskDetail
(SSE-событие, refetch задачи, возврат на вкладку): скролл сбрасывался вверх,
пользователь видел «старые сообщения → прыжок к последним». Фикс: useCallback
со стабильной идентичностью (тот же паттерн, что у мобильного accordion).
- TaskChat/useChatController: скролл к низу при открытии чата — useLayoutEffect
СИНХРОННО ДО paint (первый кадр уже у низа) + докрутка первого кадра, когда
сообщения пришли после монтирования без кэша.
Раньше образ собирался на VPS (3 ядра) при каждом деплое: 13–15 минут даунтайма.
Теперь: job build-image (buildx + кэш слоёв GHA) пушит ghcr.io/iisultanov/iistwin-app:latest
и :<sha>; SSH-скрипт скачивает образы ДО остановки контейнеров (даунтайм ~10 секунд
на подмену), fallback на сборку на сервере сохранён при недоступности GHCR.
Инфра-репо: сервису app добавлен image: (iistwin-infra@0960563).
- SSE: серверный heartbeat теперь именованное событие ping (25с) — клиент может отличить живое соединение от мёртвого; клиентский watchdog (45с без активности → принудительный reconnect), reconnect 1с вместо 3с, polling-фолбэк 5с вместо 10с
- Скролл: на скрытой вкладке скролл не трогаем (раньше автоскролл «выбрасывал» пользователя вниз при возврате); возврат на вкладку — pin к низу если был у низа; при чтении истории плавающий индикатор «↓ N новых» (оба чата)
- Персистентный кэш последних 100 сообщений чата в localStorage → initialData: мгновенный рендер после перезагрузки страницы без «Загрузка сообщений...», свежие данные в фоне; gcTime: Infinity
- SW: reload на controllerchange только на скрытой вкладке (обновление незаметно), предыдущая версия кэша сохраняется для ленивых чанков старой страницы; кэш v15
- TaskChat: atBottomRef-гвард (scrollHeight-scrollTop-clientHeight<80) — чужие
сообщения больше не дёргают чат вниз, когда пользователь читает историю;
свои сообщения и смена задачи скроллят как раньше
- ChatAttachments: img w-180 h-180 object-cover вместо max-* — высота строки
не меняется при догрузке, measureElement виртуализатора не дёргает список
- упоминания: голый @ открывает список сразу; текстовый триггер от 3 букв;
сервер ищет ILIKE по имени/отчеству/фамилии/email и отдаёт всех при пустом q;
починен мёртвый fallback-резолв (Response без .json())
- groupByDate/flattenGroupedMessages обёрнуты в useMemo
- /api/auth/me при 401 делает одну попытку refresh (раньше refresh-retry был
отключён для всех /api/auth/* — холодный старт после 15 мин простоя = логаут)
- ротация: повтор старого refresh-токена в пределах RACE_TOLERANCE_MS (5 мин)
выдаёт новую пару от successor вместо deny; cookie больше не стираются при
stale-токенах (разлогинивались все вкладки устройства); reuse после окна —
revoke family как раньше (тесты обновлены)
- refreshSession различает 401/403/400 (сессия мертва) vs 429/5xx/сеть
(transient — нет logout, отложенный retry)
- единый рефреш: useAuth keep-alive и useOfflineSync переведены на refreshSession;
межвкладочная дедупликация через BroadcastChannel('auth-refresh')
- refreshLimiter: keyGenerator buildRateLimitKey (per-user, не общий IP-бакет офиса)
Каждая точка ingest (~1/сек) broadcast'илась SSE всем клиентам орг., клиент
на каждое событие делал invalidateQueries без throttle → 2+ GET/сек → исчерпание
per-user бакета apiLimiter (1500/15мин) → все API устройства 429 (формы не грузились).
Модель теперь как у нормальных трекеров: точки пишутся в БД, карта читает по интервалу.
+4 unit-теста троттла (server/gps/sse-throttle.ts)
- шапка: [&_th]:bg-background вместо полупрозрачного bg-muted/50 — иначе сквозь
sticky-шапку проступают строки и она нечитаема (в plain-варианте фона не было вовсе)
- ресайз: table-fixed + w-full таблицы + w-max wrapper давали циклическую зависимость
ширин — колонки без заданной ширины схлопывались до нуля при первом drag.
В auto-раскладке <colgroup> задаёт предпочтительную ширину без схлопывания
check/build чисто, vitest 129/129
- ui/table: пропс wrapperClassName (twMerge), в реестре wrapper = w-max min-w-full
overflow-visible — переполнение по ширине всплывает в контейнер реестра,
скроллбар оказывается внизу видимой области, а не под всей таблицей
- TableHeader sticky top-0 (фон tr для plain-варианта скина)
- drag правого края шапки меняет ширину колонки (table-fixed + colgroup,
min 80 / max 1200 px), двойной клик — сброс; ширины хранятся в localStorage
per-form (registry-colwidths-<formId>) — у каждого пользователя свои
- настройка «Строк в ячейке» (Авто / 1..8) в диалоге настроек реестра:
CSS line-clamp на текстовых ячейках, хранится в registry-rowlines-<formId>;
оценка высоты виртуализатора ниже в однострочном режиме
check/build чисто, vitest 129/129
На мобильном (<768px) main#content был блочным: flex-1 обёртки PageTransition
работал вхолостую, высота страницы росла по контенту, весь скролл уходил в main,
а скроллбар широкой таблицы реестра оказывался внизу недостижимой высоты.
Десктопный main уже был flex flex-col (Sidebar.tsx:1687).
PLAN-user-password-fixes.md (инцидент 2026-09-07, user 55/56):
- toServiceError в client auth.service: BadRequestError → {success:false, error}
- полная клиентская валидация пароля в ProfilePasswordRow
- опциональный пароль при создании пользователя (UserModal + POST /api/users)
- onError с toast в PollCard/ReactionBar (этап 4)
- 11 новых тестов (129/129)
Убраны git stash кастомизаций и восстановление compose из бэкапа вне репо.
Источник правды для прод-compose: iisultanov/iistwin-infra (приватный),
клонируется в /opt/crm-infra deploy-ключом при каждом деплое