Анализ сайта todoiter.ru
Основное Готовность: 100%
Домен
todoiter.ru
Состояние доменного имени
?
Проверяем корректность доменного имени и наличие технических проблем на уровне домена.
Домен второго уровня идеален для продвижения.
Отличный запоминающийся домен.
Регистратор домена - RU-CENTER-RU
Дата регистрации домена 01.08.2026 (более 2 недель назад)
Ответ сервера
200 Успешный ответ
HTTP-код ответа и цепочка редиректов
?
Код 200 — страница доступна. Коды 3xx — редиректы (цепочки замедляют загрузку и размывают ссылочный вес). Коды 4xx/5xx — ошибки, поисковик не сможет проиндексировать страницу.
Сервер настроен корректно.
Цепочка редиректов:
http://todoiter.ru
308 PermanentRedirect
https://todoiter.ru/
200 OK
Безопасность
Сайт безопасен
Использование HTTPS и SSL-сертификат
?
HTTPS — обязательный стандарт. Google и Яндекс отдают предпочтение защищённым сайтам. Отсутствие SSL или просроченный сертификат ведут к предупреждениям в браузере и снижению позиций.
Не настроен HSTS (Strict-Transport-Security) — рекомендуется включить.
На сайте работает защищенный протокол ssl и сайт открывается по https.
Ssl-сертификат действителен до 30.10.2026 19:20:47.
Сервер поддерживает HTTP/3 (QUIC) — новейший протокол.
HTTP автоматически перенаправляется на HTTPS.
Поздравляем! Сайт не содержится в реестре РКН.
Кодировка
utf-8
Кодировка символов страницы
?
Стандарт — UTF-8. Неправильная кодировка вызывает нечитаемые символы и мешает поисковику корректно распознать текст страницы.
Указана кодировка на странице utf-8.
Язык
ru
Атрибут lang в HTML-теге
?
Атрибут lang (<html lang="ru">) сообщает поисковикам и браузерам, на каком языке написана страница. Помогает при ранжировании в региональном поиске.
Указан язык документа ru.
Скорость загрузки
86мс
Время отклика сервера (TTFB)
?
Time To First Byte — время до получения первого байта от сервера. Норма до 200 мс. Медленный отклик ухудшает пользовательский опыт и ранжирование: Яндекс и Google учитывают скорость страниц.
Скорость загрузки сайта 86мсек высокая. У сайта todoiter.ru очень быстрый хостинг!
Объем документа
176Кб
Размер HTML-кода страницы
?
Слишком большой HTML замедляет парсинг браузером и сканирование поисковым роботом. Рекомендуется не более 200 Кб.
Объем html-документа 176Кб оптимален.
Структура html-документа корректна.
Ресурсы
Ресурсы: 1
Внешние ресурсы страницы (CSS, JS, изображения)
?
Количество и тип подключённых ресурсов влияют на скорость загрузки. Большое число запросов увеличивает время рендеринга страницы.
Кол-во файлов ресурсов 1 достаточно.
Показать полный список ресурсов
| Тип | Название | Значение |
|---|---|---|
| js | https://telegram.org/js/telegram-web-app.js |
Серверные заголовки
Кол-во: 10
HTTP-заголовки ответа сервера
?
Заголовки сервера передают браузеру и поисковику служебную информацию: кеширование, безопасность (CSP, HSTS), сжатие (gzip). Правильная настройка ускоряет загрузку и повышает защищённость.
Найдены серверные заголовки 10шт. Подробнее про серверные заголовки.
Показать полный список серверных заголовков
| Ключ | Значение |
|---|---|
| Accept-Ranges | bytes |
| Alt-Svc | h3=":443"; ma=2592000 |
| Cache-Control | must-revalidate, no-cache |
| Date | Wed, 19 Aug 2026 11:07:25 GMT |
| ETag | "6a7d84b2-349a8" |
| Referrer-Policy | strict-origin-when-cross-origin |
| Server | nginx |
| Via | 1.1 Caddy |
| X-Content-Type-Options | nosniff |
| X-Frame-Options | SAMEORIGIN |
CMS
Не определена
Система управления сайтом (движок)
?
CMS — это движок, на котором работает сайт (WordPress, 1C-Bitrix, Tilda и др.). Знание CMS помогает понять возможности SEO-оптимизации и подобрать подходящие инструменты. «Не определена» — вероятно, самописный сайт или нестандартная сборка.
CMS не определена. Вероятно, сайт самописный либо движок надёжно скрыт. Это не ошибка.
Веб-сервер
Программное обеспечение сервера
?
Веб-сервер — это ПО, которое отдаёт страницы посетителям (nginx, Apache, IIS, LiteSpeed и др.). Определяется по серверным заголовкам ответа (Server, X-Powered-By и т.п.). «Не определён» — сервер намеренно скрывает эти заголовки, это нормальная практика безопасности.
Сайт работает на веб-сервере nginx.
Мета-теги Готовность: 32%
Title
Трекер
Заголовок страницы в браузере и поисковой выдаче
?
Title — главный SEO-заголовок страницы. Влияет на CTR в поиске и ранжирование. Оптимальная длина: 50–70 символов. Ключевые слова — ближе к началу.
Необходимо увеличить число символов в title (текущее значение мало: 6, минимум: 25, оптимально: от 40 до 45)
Дублей словоформ в title не найдено.
Description
Описание страницы в поисковой выдаче (сниппет)
?
Meta Description — текст под заголовком в выдаче. Напрямую на позиции не влияет, но влияет на CTR. Оптимальная длина: 120–160 символов.
Установите мета-тег description!
Keywords
Список ключевых слов страницы (устаревший тег)
?
Meta Keywords не учитывается Яндексом и Google для ранжирования с 2009–2012 годов. Заполнение не обязательно, но не вредит. Конкурент может использовать содержимое для анализа.
Установите мета-тег keywords!
Канонический Url
Указывает поисковику основную версию страницы
?
Canonical (rel=canonical) предотвращает проблему дублей страниц. Должен точно совпадать с URL проверяемой страницы. Неправильный canonical может передать ссылочный вес на другую страницу.
Рекомендуем прописать канонический Url.
Robots
Ошибок нет
Директивы для поисковых роботов на уровне страницы
?
Meta Robots управляет индексацией конкретной страницы: index/noindex — индексировать ли, follow/nofollow — следовать ли по ссылкам. Noindex полностью исключает страницу из поиска.
Meta-тег robots не указан. Страница свободна для индексации.
Адаптивность
width=device-width, initial-scale=1, viewport-fit=cover
Настройка масштабирования на мобильных устройствах
?
Тег viewport (<meta name="viewport">) сообщает браузеру, как масштабировать страницу на мобильных. Стандарт: width=device-width, initial-scale=1. Отсутствие — признак отсутствия мобильной версии.
Meta-тег viewport со значением-константой width=device-width задаёт ширину страницы в соответствии с размером экрана.
Meta-тег viewport со значением initial-scale=1.0 определяет масштаб 1:1, т.е. «не масштабировать».
Разметка OpenGraph
Не найдено
Мета-теги для красивых превью в соцсетях
?
OpenGraph (og:title, og:description, og:image) управляет тем, как страница выглядит при репосте в социальных сетях и мессенджерах. Отсутствие OG-тегов — невзрачный превью при шеринге.
Разметка OpenGraph не задана. Страница не оптимизирована под социальные сети. Мета-теги с разметкой Og помогают социальным роботам лучше структурировать Ваш сайт.
Все мета-теги
Кол-во: 1
Полный список мета-тегов страницы
?
Таблица всех meta-тегов, включая нестандартные. Позволяет найти опечатки, дубли и лишние теги.
Найдены мета-теги 1шт. Мета-теги не видимы для человека и предназначены для обмена информацией между веб-страницей и поисковыми системами, браузерами и другими веб-службами. С ними роботы 🤖 и устройства ведут себя более ожидаемо.
Показать полный список мета-тегов
| Тип | Название | Значение |
|---|---|---|
| name | viewport | width=device-width, initial-scale=1, viewport-fit=cover |
Оптимизация Готовность: 50%
Структура
Ошибок нет
Семантические HTML-элементы страницы
?
Проверяет наличие основных структурных элементов: nav, header, footer, main. Корректная семантическая структура помогает поисковику понять архитектуру страницы.
Структура документа корректна (теги <html> и <body> присутствуют в одном экземпляре).
Контент
Есть ошибки
Объём и качество текстового содержимого
?
Анализирует объём полезного текста на странице. Слишком мало — страница может считаться малополезной. Слишком много — ухудшается читаемость и восприятие.
Слова из title 0 встречаются в тексте редко. Добавьте в контент страницы слова из тега <title>!
Абзацев с текстом 0 слишком мало. Добавьте больше абзацев с текстом (тег <p>)!
Среднее число слов в абзаце 0 слишком мало. Сделайте контент более читаемым!
Кол-во слов 478 не очень много. Добавьте побольше текста (хотя бы 800 слов)!
Кол-во знаков контента 3114 на странице оптимально.
Заголовки
Ошибок нет
Иерархия заголовков H1–H6
?
H1 должен быть один и содержать ключевой запрос. H2–H6 описывают подразделы. Пропуск уровней (H1 → H3) и несколько H1 — типичные ошибки, снижающие понятность страницы для поисковика.
На странице присутствуют заголовки <h2> 10. Это хорошо.
На странице присутствуют заголовки <h3> 1.
Тошнота
2,65
Насколько одно слово доминирует в тексте
?
Классическая тошнота = √(частота самого повторяющегося слова). Норма до 7–8: текст воспринимается естественно. Выше — поисковик может счесть страницу переспамленной.
Тошнота страницы в пределах нормы 3.
Академич. тошнота
9,00%
Насколько текст перенасыщен ключевыми словами
?
Академическая тошнота = (частота слова / общее количество слов) × 100%. Показывает долю конкретного слова в тексте. Норма 5–15%.
Академическая тошнота страницы в пределах нормы.
Семантическое ядро
20
Наиболее часто встречающиеся слова на странице
?
Топ слов по частоте использования. Показывает, какие слова доминируют в тексте с точки зрения поисковика.
Контент страницы содержит осмысленный текст и слова.
Показать список слов
| Слово | Кол-во | Частота |
|---|---|---|
| проект | 7 | 1,46% |
| проекты | 5 | 1,05% |
| встречи | 4 | 0,84% |
| надиктовать | 4 | 0,84% |
| название | 4 | 0,84% |
| создать | 3 | 0,63% |
| сформулировать | 3 | 0,63% |
| сохранить | 3 | 0,63% |
| telegram | 3 | 0,63% |
| профиль | 3 | 0,63% |
| задачи | 2 | 0,42% |
| задача | 2 | 0,42% |
| делает | 2 | 0,42% |
| додиктовать | 2 | 0,42% |
| дословно | 2 | 0,42% |
| выберите | 2 | 0,42% |
| стримы | 2 | 0,42% |
| записать | 2 | 0,42% |
| встречу | 2 | 0,42% |
| звонок | 2 | 0,42% |
Индексация Готовность: 20%
Индексирование
Есть ошибки
Разрешено ли индексирование страницы
?
Проверяет, не закрыта ли страница от индексации через robots.txt, meta robots или X-Robots-Tag. Страница, закрытая от индексации, не появится в поисковой выдаче.
Анкоров на странице не обнаружено. Велика вероятность, что сайт - одностраничник, такой сайт продвинуть крайне затруднительно!
Robots.txt
Найден корректный robots.txt
Файл управления сканированием сайта роботами
?
Robots.txt указывает поисковым роботам, какие страницы сканировать, а какие — нет. Ошибки в файле могут случайно закрыть важные разделы от индексации.
Robots.txt настроен корректно. Размер файла: 181028 байт. Загружен за: 90мсек.
Проверяемая страница не запрещена в robots.txt.
Robots.txt доступен по постоянному адресу
Цепочка редиректов для файла robots.txt:
http://todoiter.ru/robots.txt
308 PermanentRedirect
https://todoiter.ru/robots.txt
200 OK
Показать содержимое robots.txt
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Трекер</title>
<!-- SDK Telegram Mini Apps: даёт initData (подписанные данные пользователя) и тему. -->
<script async src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
/* --------------------------------------------------------------------
Цвета берём из темы Telegram, чтобы приложение выглядело родным и в
светлой, и в тёмной теме. Значения по умолчанию — на случай запуска
вне Telegram (обычный браузер).
-------------------------------------------------------------------- */
/* Тема СВЕТЛАЯ всегда - решение владельца 10.08: "тёмная реально
неудобная; фон белый, закрашивать светло-серым". Переменные Telegram
сознательно не используются: в тёмном Telegram приложение остаётся
светлым и читаемым. */
:root {
--bg: #ffffff;
--fg: #0f1419;
/* Разбор владельца 11.08: "серое на светло-сером сливается". Мелкий текст
теперь графитовый (не светло-серый), теги темнее и с тёмным текстом,
заголовки колонок - тёмно-серые. Это читаемость, а не вкус. */
--muted: #4d5866;
--tag-bg: #e7e4f0;
--tag-fg: #26303b;
--heading: #454f5a;
--card: #f4f5f7;
/* Палитра П-Б (выбор владельца 12.08): фиолетовый акцент, фон с лёгким
сиреневым переливом. Столбец "В работе" - в цвет акцента. */
--accent: #5b4ea8;
--accent-fg: #ffffff;
--line: rgba(128, 138, 150, 0.25);
--backlog: #8b95a1;
--prog: #5b4ea8;
--done: #27ae60;
--cancel: #c0392b;
--warn: #e08a00;
}
* { box-sizing: border-box; }
input, textarea, select { caret-color: var(--fg); }
body { background-image: linear-gradient(to right, #ece4f6 0%, #fbf9fe 100%); }
a { color: var(--accent); }
/* Рамка кнопки всегда в цвет её текста: синий текст - синяя рамка,
красный - красная (решение владельца 11.08). У залитых кнопок рамка
сливается с фоном - это и есть ожидаемое "рамки не видно". */
button.btn { border-color: currentColor; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding-bottom: 24px;
}
/* Строка состояния закреплена сверху, главное меню - ВНИЗУ, под большим
пальцем (решение владельца 11.08: "частотное снизу, настройки подальше"). */
.topbar { position: sticky; top: 0; z-index: 5; background: rgba(251,251,253,.96);
border-bottom: 1px solid var(--line); }
header { padding: 10px 16px 8px; }
.sub { color: var(--muted); font-size: 13px; }
/* Вкладки: фиксированная нижняя панель. */
nav { display: flex; gap: 4px; padding: 6px 10px calc(8px + env(safe-area-inset-bottom));
background: rgba(251,251,253,.96); position: fixed; bottom: 0; left: 0; right: 0; z-index: 6;
border-top: 1px solid var(--line); align-items: flex-end; }
/* Подписи ровно в один ряд по нижнему краю, счётчик - НАД словом
(разбор 12.08: "не как зубы"). */
nav button { display: flex; flex-direction: column; align-items: center; justify-content: flex-end;
min-height: 44px; gap: 2px; }
.nav-word { line-height: 1; }
/* Плюс - в контурном кружке (просьба владельца 12.08). Всегда акцентного
цвета: и на белой панели, и на серой заливке активной вкладки контраст
есть. Раньше активный плюс красился в --accent-fg (белый) поверх серого
фона - и пропадал; больше не инвертируем. */
.nav-plus { font-style: normal; font-size: 20px; line-height: 1; font-weight: 500;
color: var(--accent); display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border: 1.6px solid var(--accent); border-radius: 50%; }
nav button.on .nav-plus { color: var(--accent); background: transparent; }
body { padding-bottom: 74px; }
nav button { flex: 1; padding: 9px 6px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-size: 14px; font-weight: 600; cursor: pointer; }
nav button.on { background: var(--card); color: var(--fg); }
section { padding: 12px 16px; display: none; }
section.on { display: block; }
/* Заголовок с круглой «i»: технический текст раздела прячется за неё и
открывается модалкой поверх всего - на подложке серый текст плохо читался
(разбор владельца 12.08). */
.h2row { display: flex; align-items: center; gap: 8px; margin: 2px 0 8px; }
.h2row h2 { margin: 0; }
.h2row.mt { margin-top: 22px; }
.info-btn { flex: none; width: 22px; height: 22px; border-radius: 50%;
border: 1.5px solid var(--accent); background: transparent; color: var(--accent);
font: italic 700 13px/1 Georgia, "Times New Roman", serif; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; padding: 0; }
.info-overlay { display: none; position: fixed; inset: 0; z-index: 50;
background: rgba(28,39,51,.55); padding: 20px; }
.info-overlay.on { display: flex; align-items: center; justify-content: center; }
.info-panel { position: relative; background: #fff; color: var(--fg); border-radius: 14px;
width: 100%; max-width: 520px; max-height: 80vh; overflow: auto; padding: 18px;
box-shadow: 0 18px 50px rgba(0,0,0,.35); }
.info-panel h3 { margin: 0 44px 10px 0; font-size: 17px; }
.info-panel .info-text { font-size: 14.5px; line-height: 1.5; white-space: pre-line; }
.info-x { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px;
border-radius: 50%; border: 1px solid var(--line); background: #fff; color: var(--muted);
font-size: 20px; line-height: 1; cursor: pointer; }
/* Карточки и общие блоки */
.card { background: var(--card); border-radius: 12px; padding: 12px; margin-bottom: 10px; }
.card { box-shadow: inset 0 0 0 1px rgba(139,149,161,.22); }
.row { display: flex; gap: 8px; align-items: center; }
.row.wrap { flex-wrap: wrap; }
.grow { flex: 1; min-width: 0; }
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
input, select, textarea {
width: 100%; padding: 10px; border: 1px solid var(--line); border-radius: 9px;
background: var(--bg); color: var(--fg); font-size: 15px; font-family: inherit;
}
button.btn { padding: 10px 14px; border: 0; border-radius: 9px; background: var(--accent); color: var(--accent-fg); font-size: 14px; font-weight: 600; cursor: pointer; }
button.btn.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
button.btn.sm { padding: 6px 10px; font-size: 13px; }
/* Доска */
.col-head { display: inline-flex; align-items: center; gap: 8px; margin: 16px 0 8px;
background: rgba(255,255,255,.85); border-radius: 8px; padding: 3px 10px; }
.dot { width: 9px; height: 9px; border-radius: 50%; }
.col-title { font-weight: 700; font-size: 14px; color: var(--heading); }
.count { color: var(--muted); font-size: 13px; }
.task { background: var(--card); border-radius: 12px; padding: 11px 12px; margin-bottom: 8px;
cursor: pointer; box-shadow: inset 0 0 0 1px rgba(139,149,161,.22); }
/* Название - явная ссылка: владелец не должен угадывать, куда нажимать. */
.task .title { font-weight: 600; margin-bottom: 5px; word-break: break-word; cursor: pointer;
color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
/* Фильтры отделены от формы создания: это разные блоки, а не одна каша. */
.nav-badge { display:inline-block; min-width:16px; height:16px; line-height:16px;
border-radius:8px; background:#e53935; color:#fff; font-size:11px; padding:0 4px;
text-align:center; line-height:16px; }
.filters-col { display: flex; flex-direction: column; gap: 8px; }
.filters-col select, .filters-col input { width: 100%; font-size: 14px; }
.filters-head { cursor: pointer; font-weight: 600; font-size: 13.5px; color: var(--heading);
list-style-position: inside; }
.filters-head .sub { margin-left: 6px; font-weight: 400; }
.lnk { background: none; border: 0; color: var(--muted); cursor: pointer;
font-size: 12.5px; text-decoration: underline; padding: 4px 0; }
details.fold { margin-top: 14px; }
details.fold > summary { cursor: pointer; font-weight: 700; font-size: 16px;
color: var(--fg); list-style-position: inside; margin-bottom: 6px; }
input[type="time"], input[type="date"] { max-width: 180px; }
@media (min-width: 700px) {
.filters-col { display: grid; grid-template-columns: 1fr 1fr; }
/* Поиск на широком экране занимает всю строку - он главный фильтр. */
.filters-col input[type="search"] { grid-column: 1 / -1; }
}
/* Индикатор записи: красная точка в кнопке + бегущая волна громкости справа.
Волна бежит слева направо (свежий звук появляется слева) - решение
владельца; она ничего не значит, кроме "тебя слышно: тише-громче". */
.rec-dot { display:inline-block; width:9px; height:9px; border-radius:50%; background:#e53935;
margin-right:7px; animation: recpulse 1s infinite; vertical-align: middle; }
/* Красный кружок = "здесь записывают голос". Один знак на все кнопки
надиктовки в приложении (решение владельца 12.08). */
.mic-dot { display:inline-block; width:10px; height:10px; border-radius:50%;
background:#e53935; margin-right:7px; vertical-align: middle; }
input.need, textarea.need, select.need { border-color: #e53935; box-shadow: 0 0 0 2px rgba(229,57,53,.12); }
canvas.rec-wave { height: 30px; flex: 1 1 60px; min-width: 60px; max-width: 220px;
align-self: center; }
@keyframes recpulse { 50% { opacity:.3; } }
.meta { display: flex; flex-wrap: wrap; gap: 6px; font-size: 12px; color: var(--muted); }
.tag { background: var(--tag-bg); color: var(--tag-fg); border-radius: 6px; padding: 2px 7px;
border: 0; line-height: 1.35; }
.tag.warn { background: rgba(224,138,0,.18); color: var(--warn); }
/* Просрочка - красным, но приглушённым (разбор 12.08: яркий бил по глазам и
из-за irradiation казался выше соседних тегов). Та же коробка, что у всех
тегов: без рамки, тот же padding и line-height - высота совпадает точно. */
.tag.due-over { background: #c66; color: #fff; }
.meta-break { flex-basis: 100%; height: 0; }
.tag.hi { background: rgba(39,174,96,.16); color: var(--done); }
.tag.lo { background: rgba(192,57,43,.14); color: var(--cancel); }
/* Цитата-первоисточник: почему задача вообще появилась */
.quote { margin-top: 7px; padding: 7px 9px; border-left: 3px solid var(--line); font-size: 13px; color: var(--muted); font-style: italic; word-break: break-word; }
.hint { margin-top: 7px; font-size: 12px; color: var(--muted); }
.acts { display: flex; gap: 6px; margin-top: 9px; flex-wrap: wrap; }
.empty { color: var(--muted); font-size: 13px; padding: 8px 0 14px; }
/* --------------------------------------------------------------------
Доска на десктопе — колонки слева направо.
В Mini App экран узкий, там колонки идут сверху вниз: это единственный
способ уместить четыре статуса на телефоне. На широком экране такая
раскладка выглядит недоразумением — канбан читается по горизонтали,
потому что взгляд идёт по ходу работы: бэклог → в работе → сделано.
Поэтому от 900 пикселей переключаемся на четыре колонки в ряд.
-------------------------------------------------------------------- */
@media (min-width: 900px) {
body { max-width: 1400px; margin: 0 auto; }
nav button { flex: 0 0 auto; min-width: 140px; }
section { padding: 16px 24px; }
#board { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; align-items: start; }
#board .col { min-width: 0; }
#board .col-head { margin-top: 0; }
/* Колонка получает фон, чтобы граница между статусами читалась без линий. */
#board .col-body { background: rgba(128,138,150,.07); border-radius: 12px; padding: 8px; min-height: 60px; }
}
/* Карточка задачи поверх доски */
#taskview { position: fixed; inset: 0; background: var(--bg); z-index: 20; overflow-y: auto; padding: 0 0 40px; }
#taskview .tv-bar { position: sticky; top: 0; background: var(--bg); border-bottom: 1px solid var(--line); padding: 10px 16px; display: flex; gap: 10px; align-items: center; }
#taskview .tv-body { padding: 14px 16px; max-width: 780px; margin: 0 auto; }
#taskview h3 { font-size: 17px; margin: 14px 0 6px; }
.cmt { border-left: 3px solid var(--line); padding: 8px 10px; margin-bottom: 10px; }
.cmt .who { font-size: 12px; color: var(--muted); margin-bottom: 3px; }
.cmt .body { word-break: break-word; white-space: pre-wrap; }
.cmt .replies { margin: 8px 0 0 14px; }
.att { display: flex; gap: 8px; align-items: center; font-size: 14px; margin-bottom: 6px; word-break: break-all; }
.hist { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.banner { border-radius: 10px; padding: 10px 34px 10px 12px; font-size: 13.5px;
position: fixed; top: 10px; left: 12px; right: 12px; z-index: 20;
box-shadow: 0 6px 22px rgba(20,30,40,.22); border: 1px solid rgba(0,0,0,.06); }
.banner.warn { background: #fff4e0; color: #8a5300; }
.banner.err { background: #fdeceb; color: #a52019; }
.banner.ok { background: #eaf6ec; color: #1e6b2a; }
.banner-x { position: absolute; top: 4px; right: 6px; border: 0; background: none;
font-size: 20px; line-height: 1; color: inherit; opacity: .6; cursor: pointer; padding: 2px 6px; }
.hidden { display: none; }
h2 { font-size: 15px; margin: 18px 0 6px; }
</style>
</head>
<body>
<!-- Строки состояния наверху больше нет: "Алексей, открыто 7" ничего не
давало (разбор 12.08). Счётчики живут на кнопках нижнего меню. -->
<div id="sub" style="display:none"></div>
<div id="banner" class="banner hidden"></div>
<nav id="mainnav">
<button id="tab-create" onclick="switchTab('create')" title="Новая задача"><i class="nav-plus">+</i></button>
<button id="tab-board" class="on" onclick="switchTab('board')"><span id="board-badge" class="nav-badge" style="display:none"></span><span class="nav-word">Задачи</span></button>
<button id="tab-meetings" onclick="switchTab('meetings')"><span class="nav-word">Встречи</span></button>
<button id="tab-people" onclick="switchTab('people')"><span id="people-badge" class="nav-badge" style="display:none"></span><span class="nav-word">Люди</span></button>
<button id="tab-more" onclick="switchTab('more')"><span class="nav-word">Ещё</span></button>
</nav>
<!-- Пояснения разделов: технический текст спрятан за круглую «i» у заголовка,
по тапу - панель поверх всего на сплошной подложке, крестик закрывает
(разбор владельца 12.08: серый текст на подложке плохо читался). -->
<div id="info-overlay" class="info-overlay" onclick="closeInfo(event)">
<div class="info-panel">
<button class="info-x" onclick="closeInfo()" aria-label="Закрыть">×</button>
<h3 id="info-title"></h3>
<div class="info-text" id="info-text"></div>
</div>
</div>
<!-- ================== СОЗДАТЬ ================== -->
<section id="sec-create">
<h2 style="margin-top:0">Новая задача</h2>
<!-- Шпаргалка при надиктовке: смотришь глазами и не сбиваешься
(идея владельца 12.08). -->
<div class="card" style="margin-bottom:10px">
<div class="sub" style="margin-bottom:6px">Надиктуйте в таком порядке - и задача заведётся сама:</div>
<div style="font-weight:600;line-height:1.9">
1. Какой стрим<br />
2. Какой проект<br />
3. Что нужно сделать<br />
4. Кто делает<br />
5. К какому сроку
</div>
<div class="sub" style="margin-top:6px">Чего-то не скажете - спрошу в разборе, там же можно додиктовать.</div>
</div>
<div class="card">
<textarea id="new-title" class="grow" rows="2"
placeholder="Какой стрим, какой проект, что сделать, кто и к какому сроку - и жмите Создать или Надиктовать"></textarea>
<div class="acts" style="margin-top:8px">
<button class="btn" onclick="createTask()">Создать</button>
<button class="btn ghost" data-mic="new-title"><i class="mic-dot"></i>Надиктовать</button>
</div>
<div id="dict-mode" style="display:none;margin-top:8px">
<div class="acts">
<span class="sub" style="align-self:center">Как записать:</span>
<button class="btn sm" onclick="previewDictate('smart')">Сформулировать</button>
<button class="btn ghost sm" onclick="previewDictate('verbatim')">Дословно</button>
</div>
<div class="sub" style="margin-top:4px">Сформулировать - ИИ разложит сказанное в поля:
название, описание, кто, когда. Дословно - слова сохранятся как есть (ИИ только
расставит знаки), название всё равно сформулируется.</div>
</div>
<div class="card" id="dict-preview" style="display:none;margin-top:8px">
<div class="sub" style="margin-bottom:6px">Проверьте разбор: чего не хватает - додиктуйте
или выберите руками. Сохраняется ровно то, что видите.</div>
<div id="dp-rows"></div>
<label style="margin-top:8px">Проект</label>
<select id="dp-project"></select>
<div class="acts" style="margin-top:10px">
<button class="btn" onclick="saveDictPreview()">Сохранить</button>
<button class="btn ghost sm" data-mic="new-title">Додиктовать</button>
<button class="btn ghost sm" onclick="previewDictate('smart')">Сформулировать иначе</button>
<button class="btn ghost sm" onclick="cancelDictPreview()">Отмена</button>
</div>
</div>
</div>
</section>
<!-- ================== ЗАДАЧИ ================== -->
<section id="sec-board" class="on">
<div class="card">
<details id="filters-box">
<summary class="filters-head">Фильтры, сортировка и поиск<span class="sub" id="f-active"></span></summary>
<div class="filters-col" style="margin-top:8px">
<select id="f-stream" onchange="render()" style="display:none"><option value="all">Стрим: все</option></select>
<select id="f-project" onchange="render()"><option value="all">Проект: все</option></select>
<select id="f-assignee" onchange="render()"><option value="all">Исполнитель: все</option></select>
<select id="f-status" onchange="render()">
<option value="all">Статус: все</option>
<option value="backlog">Бэклог</option>
<option value="in_progress">В работе</option>
<option value="done">Сделано</option>
<option value="cancelled">Отменено</option>
</select>
<select id="f-sort" onchange="render()">
<option value="new">Сначала новые</option>
<option value="old">Сначала старые</option>
<option value="due">По сроку: горящие сверху</option>
</select>
<input id="f-search" type="search" placeholder="Поиск: название, описание, человек"
oninput="render()" autocomplete="off" />
<div><button class="btn ghost sm" onclick="resetFilters()">Сбросить всё</button></div>
</div>
</details>
</div>
<div id="board"></div>
</section>
<!-- ================== КАРТА ================== -->
<section id="sec-map">
<div class="acts" style="margin-bottom:6px"><button class="btn ghost sm" onclick="switchTab('more')">← Ещё</button></div>
<div class="h2row"><h2>Карта проекта</h2><button class="info-btn" onclick="openInfo('map')" title="Пояснение">i</button></div>
<div id="map-list"></div>
</section>
<!-- ================== ПРОЕКТЫ ================== -->
<section id="sec-streams">
<div class="acts" style="margin-bottom:6px"><button class="btn ghost sm" onclick="switchTab('more')">← Ещё</button></div>
<div class="h2row"><h2>Стримы</h2><button class="info-btn" onclick="openInfo('streams')" title="Пояснение">i</button></div>
<div id="streams-overview"></div>
<div class="card">
<label for="st-name">Новый стрим</label>
<input id="st-name" placeholder="Например: Азартные игры" />
<div class="acts">
<button class="btn" onclick="createStream()">Создать стрим</button>
</div>
</div>
</section>
<!-- ================== ВСТРЕЧИ ================== -->
<section id="sec-meetings">
<div class="h2row"><h2>Записать встречу</h2><button class="info-btn" onclick="openInfo('meetings')" title="Пояснение">i</button></div>
<div class="card">
<div id="room-none">
<label for="rm-title">Название встречи</label>
<input id="rm-title" placeholder="Например: Планёрка в переговорке" />
<label for="rm-project">Проект</label>
<select id="rm-project"></select>
<div class="acts">
<button class="btn" id="rm-open" onclick="openRoom()">Открыть комнату</button>
<button class="btn ghost" id="rm-call" onclick="startCallRec()">Записать звонок с этого компьютера</button>
</div>
<div class="sub" id="rm-call-hint" style="margin-top:6px">Для чужого Zoom/Meet: откройте звонок
во вкладке браузера (у Zoom - «Join from your browser»), нажмите кнопку и выберите ту вкладку
с галочкой «делиться звуком». Наушники не мешают - звук берётся до них.</div>
<div class="acts" style="margin-top:4px">
<button class="btn ghost sm" onclick="openCallLink()">Ссылка на рекордер для обычного браузера</button>
</div>
<div id="call-link-box" style="display:none;margin-top:6px">
<input id="call-link-url" readonly />
<div class="acts"><button class="btn sm" onclick="copyCallLink()">Скопировать ссылку</button></div>
<div class="sub">Откройте её в Chrome на этом компьютере - вход уже вшит, действует 12 часов.</div>
</div>
</div>
<div id="call-live" style="display:none">
<div><b>Идёт запись звонка</b> <span class="tag warn" id="call-timer">0:00</span></div>
<div class="sub" style="margin-top:4px">Пишутся два звука: вкладка со звонком и ваш микрофон.
Не закрывайте эту страницу до конца звонка.</div>
<div class="acts" style="margin-top:8px">
<button class="btn" id="call-finish" onclick="finishCallRec(true)">Завершить и отправить</button>
<button class="btn ghost sm" onclick="cancelCallRec()">Отменить без отправки</button>
</div>
</div>
<div id="room-live" style="display:none">
<div><b id="rm-live-title"></b> <span class="tag" id="rm-live-status"></span></div>
<div class="sub" id="rm-live-hint" style="margin-top:6px"></div>
<div class="acts">
<button class="btn" id="rm-rec" onclick="toggleRoomRec()">Включить микрофон</button>
<button class="btn ghost sm" onclick="copyRoomInvite()">Позвать участников</button>
<button class="btn ghost" id="rm-close" onclick="closeRoom()">Завершить встречу</button>
</div>
</div>
</div>
<div id="rooms-list"></div>
<div class="h2row mt"><h2>Встречи</h2><button class="info-btn" onclick="openInfo('meetingsUpload')" title="Пояснение">i</button></div>
<div class="card">
<label for="mt-title">Название встречи</label>
<input id="mt-title" placeholder="Например: Планёрка по кассе" />
<label for="mt-stream">Стрим</label>
<select id="mt-stream" onchange="fillMeetingProjects()"><option value="all">все стримы</option></select>
<label for="mt-project">Проект</label>
<select id="mt-project"></select>
<label for="mt-file">Файл записи</label>
<input id="mt-file" type="file" accept="audio/*,video/mp4,video/webm" />
<div class="acts">
<button class="btn" id="mt-upload" onclick="uploadMeeting()">Загрузить запись</button>
</div>
</div>
<div id="meetings-list"></div>
</section>
<!-- ================== ЛЮДИ ================== -->
<section id="sec-people">
<div class="h2row"><h2>Люди</h2><button class="info-btn" onclick="openInfo('people')" title="Пояснение">i</button></div>
<div id="people-all"></div>
<details class="fold">
<summary>Добавить участника - вход в todoiter</summary>
<div class="card">
<div class="sub">Участник получает вход в вашу доску и уведомления. Люди из групп и
переписок попадают в картотеку сами - для задач добавлять их сюда не нужно.
Скоро приглашать можно будет ссылкой (человек сам войдёт и укажет имя); пока -
по Telegram ID.</div>
<label for="m-tg">Telegram ID участника</label>
<input id="m-tg" inputmode="numeric" placeholder="123456789" />
<div class="sub" style="margin-top:4px">Где взять ID: напишите боту @userinfobot -
он пришлёт ваш числовой id; так же его узнаёт сам человек и присылает вам.</div>
<label for="m-name">Имя</label>
<input id="m-name" placeholder="Пётр" />
<div class="acts"><button class="btn" onclick="addMember()">Добавить</button></div>
</div>
</details>
</section>
<section id="sec-projects">
<div class="acts" style="margin-bottom:6px"><button class="btn ghost sm" onclick="switchTab('more')">← Ещё</button></div>
<div class="h2row"><h2>Проекты</h2><button class="info-btn" onclick="openInfo('projects')" title="Пояснение">i</button></div>
<div class="card">
<label for="pf-stream">Стрим</label>
<select id="pf-stream" onchange="renderProjects()"><option value="all">все стримы</option></select>
</div>
<div id="projects-list"></div>
<div class="card">
<label for="p-name">Новый проект</label>
<input id="p-name" placeholder="Например: Сайт" />
<label for="p-stream">Стрим</label>
<select id="p-stream"></select>
<label for="p-legend">Легенда (контекст для ИИ)</label>
<textarea id="p-legend" rows="2" placeholder="О чём проект: цель, участники, что считается задачей"></textarea>
<div class="acts">
<button class="btn" onclick="createProject()">Создать проект</button>
<button class="btn ghost" data-mic="p-legend"><i class="mic-dot"></i>Надиктовать легенду</button>
</div>
</div>
<div class="h2row mt"><h2>Группы</h2><button class="info-btn" onclick="openInfo('groups')" title="Пояснение">i</button></div>
<div class="card">
<label for="c-chat">Группа</label>
<select id="c-chat"></select>
<label for="c-project">Проект</label>
<select id="c-project"></select>
<div class="acts">
<button class="btn" onclick="mapChannel()">Привязать</button>
<button class="btn ghost" onclick="load()">Обновить список</button>
</div>
</div>
<div id="channels-list"></div>
</section>
<!-- ================== ЕЩЁ ================== -->
<section id="sec-more">
<div class="h2row"><h2>Ещё</h2><button class="info-btn" onclick="openInfo('more')" title="Пояснение">i</button></div>
<div id="onboarding"></div>
<div class="card" onclick="switchTab('streams')" style="cursor:pointer">
<b>Стримы</b><div class="sub">Направления, в которых живут проекты.</div></div>
<div class="card" onclick="switchTab('projects')" style="cursor:pointer">
<b>Проекты и группы</b><div class="sub">Проекты, их легенды и привязка Telegram-групп.</div></div>
<div class="card" onclick="switchTab('map')" style="cursor:pointer">
<b>Карта</b><div class="sub">Главные события по месяцам.</div></div>
<div class="card" onclick="switchTab('profile')" style="cursor:pointer">
<b>Профиль</b><div class="sub">Вход, обо мне, настройки пространства, расписания.</div></div>
</section>
<!-- ================== ПРОФИЛЬ ================== -->
<section id="sec-profile">
<div class="acts" style="margin-bottom:6px"><button class="btn ghost sm" onclick="switchTab('more')">← Ещё</button></div>
<div class="h2row"><h2>Профиль</h2><button class="info-btn" onclick="openInfo('profile')" title="Пояснение">i</button></div>
<div class="card" id="profile-card"><div class="sub">загрузка…</div></div>
<details class="fold">
<summary>Способы входа</summary>
<div class="card">
<div class="sub">Подтвердите телефон и почту - это второй и третий способ входа помимо Telegram.</div>
<label for="v-type">Что подтверждаем</label>
<select id="v-type">
<option value="email">Почта</option>
<option value="phone">Телефон</option>
</select>
<label for="v-ident">Адрес или номер</label>
<input id="v-ident" placeholder="name@example.com" />
<div class="acts"><button class="btn" onclick="startVerify()">Выслать код</button></div>
<div id="v-confirm" class="hidden">
<label for="v-code">Код из сообщения</label>
<input id="v-code" inputmode="numeric" placeholder="6 цифр" />
<div class="acts"><button class="btn" onclick="confirmVerify()">Подтвердить</button></div>
</div>
</div>
</details>
<details class="fold">
<summary>Обо мне - для советника</summary>
<div class="card">
<div class="sub" style="margin-bottom:6px">Резюме для советника: кто вы, чем занимаетесь,
цели, как вас оценивают. Советник опирается на это, когда анализирует проекты и
предлагает задачи. Заполняется про себя; можно надиктовать.</div>
<label for="pr-about">Кто я и чем занимаюсь</label>
<textarea id="pr-about" rows="2" placeholder="Например: руковожу направлением азартных игр, отвечаю за запуск казино"></textarea>
<label for="pr-goals">Мои цели</label>
<textarea id="pr-goals" rows="2" placeholder="Например: запустить кассу к сентябрю, выйти на 1000 игроков"></textarea>
<label for="pr-eval">Как меня оценивают</label>
<textarea id="pr-eval" rows="2" placeholder="Например: сроки запусков, выручка направления"></textarea>
<div class="acts">
<button class="btn" onclick="saveProfile()">Сохранить профиль</button>
<button class="btn ghost" data-mic="pr-goals"><i class="mic-dot"></i>Надиктовать цели</button>
</div>
</div>
</details>
<details class="fold">
<summary>Настройки пространства</summary>
<div class="card">
<div class="sub" style="margin-bottom:6px">Когда приходят сводки и кому пишут отчёты бота
(недельный о ресурсах, вопросы по личке, понедельничное движение). Меняет лидер.</div>
<label for="os-evening">Вечерняя сводка</label>
<input id="os-evening" type="time" value="19:30" />
<label for="os-morning">Утренняя сводка (молчит без новостей)</label>
<input id="os-morning" type="time" value="09:00" />
<label for="os-tz">Часовой пояс</label>
<select id="os-tz"></select>
<label for="os-recipient">Получатель отчётов</label>
<select id="os-recipient"></select>
<div class="acts"><button class="btn" onclick="saveOrgSettings()">Сохранить</button></div>
</div>
</details>
<details class="fold">
<summary>Расписания - что бот делает сам</summary>
<div class="sub" style="margin:6px 0 8px">
Сводки, уведомления, разборы. У каждого видно, когда запускалось и чем кончилось.
</div>
<div id="jobs-list"></div>
</details>
</section>
<!-- Карточка задачи. Открывается поверх доски по клику на заголовок. -->
<div id="taskview" class="hidden"></div>
<script>
// ============================================================================
// Mini App трекера. Без сборки и зависимостей — один файл, открывается как есть.
//
// Как работает аутентификация:
// Telegram отдаёт подписанный initData. Мы кладём его в заголовок
// x-telegram-init-data КАЖДОГО запроса; сервер проверяет подпись сам и по ней
// определяет, кто это. Никаких user_id из клиента серверу не передаём —
// им нельзя доверять.
//
// Если бэкенд недоступен или страницу открыли вне Telegram — включается
// ДЕМО-режим с локальными данными, чтобы интерфейс можно было изучить.
// ============================================================================
// Пусто = тот же origin, что и страница (nginx проксирует /api на бэкенд).
const API_BASE = "";
// Подложка В2 (выбор владельца): еле заметные контурные предметы дел и
// бизнес-словечки поверх сиреневого перелива. Рисуется одним svg-тайлом,
// цвет почти сливается с фоном - "видно, что есть, но не отвлекает".
(function mountBackdrop() {
// Правка владельца 12.08 (вечер, вторая): "картиночки вообще уберём, добавь
// слова". Подложка теперь ТОЛЬКО из слов-метрик - его список плюс прежняя
// четвёрка. Каждая запись: слово, x, y, поворот в дизайн-сетке 300x300;
// весь узор ужат scale(0.4) в плитку 120x120 (плотность как у одобренного
// скрина). Слова расставлены руками так, чтобы не налезать друг на друга и
// не пересекать край плитки - на стыке плиток шва не видно. M&A несёт
// &: без него SVG-парсер валится на голом амперсанде, и фон пустеет.
var c = "#ddd4ec";
var words = [
["IPO", 28, 34, -12], ["Dispatch", 120, 52, 8], ["SH", 252, 30, -6],
["EBITDA", 30, 92, 10], ["ETA", 150, 104, -10], ["GMV", 230, 96, 14],
["Churn", 60, 152, -8], ["nMFG", 170, 160, 12], ["KPI", 255, 155, -14],
["SubGMV", 24, 210, 6], ["MPH", 150, 218, -12], ["NI", 225, 205, 8],
["Surge", 55, 268, -6], ["M&A", 165, 275, 10],
];
var w = words.map(function (p) {
return '<text x="' + p[1] + '" y="' + p[2] + '" transform="rotate(' + p[3] + " " +
p[1] + " " + p[2] + ')" font-family="Menlo,monospace" font-size="22" letter-spacing="2" fill="none" stroke="' +
c + '" stroke-width="1.8">' + p[0] + "</text>";
}).join("");
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">' +
'<g transform="scale(0.4)" fill="none">' + w + "</g></svg>";
try {
// Градиент ГОРИЗОНТАЛЬНЫЙ, слева направо: слева чуть темнее (лавандовый),
// справа почти белый (правка владельца 12.08). Цвет меняется по X, вдоль Y
// постоянен - горизонтального шва не видно. Оттенок сделан бледнее прежнего
// ("ещё потоньше"), слова тоже осветлены.
document.body.style.backgroundImage =
"url('data:image/svg+xml;utf8," + encodeURIComponent(svg) + "'), " +
"linear-gradient(to right, #ece4f6 0%, #fbf9fe 100%)";
} catch (e) { /* подложка - украшение, не зависимость */ }
})();
// Пояснения разделов в модалке (кнопки «i» у заголовков). Текст держим здесь,
// а не в inline-onclick: длинные строки с кавычками в атрибуте ломаются.
var INFO = {
people: ["Люди", "Все, кого трекер встречал: участники пространства, люди из групп и личной переписки. Имя можно поправить - в отчётах и задачах будет так, как удобно вам. Логин ведёт в профиль Telegram."],
streams: ["Стримы", "Стрим - направление, в нём живут проекты. Выберите стрим на доске, чтобы видеть только его задачи и проекты. У стрима можно завести легенду - контекст направления, он помогает ИИ предложить проект при надиктовке."],
map: ["Карта проекта", "Главные события по месяцам - чтобы через полгода вспомнить, что и как было сделано, не перечитывая сотни задач. Собирается сама в начале каждого месяца."],
meetings: ["Записать встречу", "Встреча в переговорке или микс: каждый участник открывает приложение, заходит на вкладку Встречи и жмёт «Включить микрофон» - его телефон пишет ЕГО голос. После завершения дорожки сами собираются в один транскрипт с настоящими именами. Участник должен быть в вашем пространстве (Люди - Добавить участника); кнопка «Позвать участников» копирует приглашение со ссылкой на приложение - разошлите его в чат. Телефон держите с открытым приложением и не блокируйте экран, пока идёт запись."],
meetingsUpload: ["Загрузка записи", "Загрузите запись встречи (диктофон, экспорт из Zoom) - она расшифруется с разметкой голосов («Спикер 1», «Спикер 2»), и явные договорённости станут задачами с цитатой и таймкодом. До 4 часов, файл до 130 МБ. Запись после расшифровки удаляется - остаются транскрипт и задачи."],
projects: ["Проекты", "Проекты живут внутри стримов. У каждого - легенда: контекст для ИИ, чем точнее описано, что считается задачей, тем осмысленнее разбор. Фильтр по стриму сужает список."],
groups: ["Группы", "Добавьте бота в рабочую группу и напишите в неё ЛЮБОЕ сообщение - по нему бот увидит группу, и она появится в списке (обновите его кнопкой ниже). Затем выберите, к какому проекту она относится."],
more: ["Ещё", "Настройка пространства: заводится один раз, дальше сюда заходят редко. Отсюда открываются Стримы, Проекты и группы, Карта и Профиль."],
profile: ["Профиль", "Ваш вход и роль, блок «Обо мне» для советника, настройки пространства (когда приходят сводки и кому пишут отчёты бота) и расписания. Заполняется обычно один раз."],
};
function openInfo(key) {
var e = INFO[key];
if (!e) return;
document.getElementById("info-title").textContent = e[0];
document.getElementById("info-text").textContent = e[1];
document.getElementById("info-overlay").classList.add("on");
}
function closeInfo(ev) {
// Клик по самой подложке (target === overlay) закрывает; клик внутри панели
// всплывает сюда же, но target !== overlay - тогда не закрываем. Крестик
// зовёт closeInfo() без события и закрывает всегда.
if (ev && ev.target !== ev.currentTarget) return;
document.getElementById("info-overlay").classList.remove("on");
}
document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeInfo(); });
// SDK Telegram может НЕ доехать (telegram.org блокируют некоторые сети РФ,
// а вебвью ходит обычной сетью, не каналами Telegram). Приложение обязано
// жить без него: initData Telegram и так передаёт в хэше адреса
// (#tgWebAppData=...), тему - в tgWebAppThemeParams. SDK, если догрузится,
// подхватывается для ready()/expand() - но вход от него не зависит.
function initDataFromHash() {
try {
var h = window.location.hash ? window.location.hash.slice(1) : "";
if (!h) return "";
var params = new URLSearchParams(h);
return params.get("tgWebAppData") || "";
} catch (e) { return ""; }
}
var tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null;
if (tg) { try { tg.ready(); tg.expand(); } catch (e) {} }
const initData = (tg && tg.initData) ? tg.initData : initDataFromHash();
// Токен рекордера (#rt=...) - вход в обычном браузере без Telegram.
var recordToken = (function () {
try {
var h = window.location.hash ? window.location.hash.slice(1) : "";
return new URLSearchParams(h).get("rt") || "";
} catch (e) { return ""; }
})();
function authHeaders() {
// ВНИМАНИЕ БУДУЩЕМУ СЕБЕ: literal ниже однажды был съеден глобальной
// заменой и превратился в вызов самой себя - бесконечная рекурсия клала
// приложение в демо-режим ("Maximum call stack size exceeded").
if (initData) return { "x-telegram-init-data": String(initData) };
if (recordToken) return { "x-record-token": recordToken };
return {};
}
// SDK догрузился позже первой отрисовки - вежливо сообщаем Telegram, что живы.
if (!tg) {
var tgWait = setInterval(function () {
if (window.Telegram && window.Telegram.WebApp) {
tg = window.Telegram.WebApp;
try { tg.ready(); tg.expand(); } catch (e) {}
clearInterval(tgWait);
}
}, 300);
setTimeout(function () { clearInterval(tgWait); }, 15000);
}
const COLUMNS = [
{ key: "backlog", label: "Бэклог", color: "var(--backlog)" },
{ key: "in_progress", label: "В работе", color: "var(--prog)" },
{ key: "done", label: "Сделано", color: "var(--done)" },
{ key: "cancelled", label: "Отменено", color: "var(--cancel)" },
];
// Куда можно перевести задачу из текущего статуса (подписи для кнопок).
const NEXT = {
backlog: [["in_progress", "В работу"], ["cancelled", "Отменить"]],
in_progress: [["done", "Готово"], ["backlog", "В бэклог"]],
done: [["in_progress", "Вернуть"]],
cancelled: [["backlog", "Вернуть"]],
};
const DEMO_TASKS = [
{ id: "d1", title: "Скинуть макет лендинга", status: "backlog", assignee_name: "Коля", due: "2026-08-01",
project_name: "Сайт", ingest_status: "pending", extraction_confidence: "low",
source_quote: "Коля, скинь до пятницы макет лендинга" },
{ id: "d2", title: "Согласовать текст оффера", status: "backlog", assignee_name: null,
project_name: "Сайт", ingest_status: "sent", extraction_confidence: "med",
source_quote: "текст оффера надо согласовать с юристами" },
{ id: "d3", title: "Настроить аналитику", status: "in_progress", assignee_name: "Петя", due: "2026-07-30",
project_name: "Сайт", ingest_status: "sent", extraction_confidence: null, source_quote: null },
{ id: "d4", title: "Выбрать домен", status: "done", assignee_name: "Петя",
project_name: "Сайт", ingest_status: "sent", extraction_confidence: null, source_quote: null },
];
let state = { tasks: [], projects: [], channels: [], jobs: [], people: [], highlights: [], streams: [], meetings: [], rooms: [], dictVoice: false, allPeople: [], members: [], orgSettings: null, myProfile: null, me: null, demo: false, tab: "board", card: null, commentOrder: "new" };
/** Экранирование пользовательского текста — защита от XSS при вставке в HTML. */
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
.replace(/"/g, """).replace(/'/g, "'");
}
function banner(text, kind) {
const el = document.getElementById("banner");
if (!text) { el.className = "banner hidden"; el.innerHTML = ""; return; }
el.className = "banner " + (kind || "warn");
// Крестик вместо таймера: сообщение ждёт, пока его прочтут и закроют
// (разбор 12.08: "исчезает быстрее, чем успеваю прочитать").
el.innerHTML = '<button class="banner-x" onclick="banner(\'\')" title="Закрыть">×</button>' +
'<span></span>';
el.lastChild.textContent = text;
// Успешное само уходит через 6 секунд - его читать не обязательно; всё
// остальное (предупреждения и ошибки) висит до крестика.
if (kind === "ok") {
setTimeout(function () {
if (el.lastChild && el.lastChild.textContent === text) banner("");
}, 6000);
}
}
/** Запрос к API с обязательным заголовком initData. */
async function api(path, options) {
options = options || {};
const headers = Object.assign(authHeaders(), options.headers || {});
// Content-Type ставим ТОЛЬКО когда реально отправляем тело. Иначе сервер ждёт
// JSON, не находит его и отвечает 400 — именно так ломалась кнопка «Подтвердить».
if (options.body !== undefined && options.body !== null) {
headers["Content-Type"] = "application/json";
}
const res = await fetch(API_BASE + path, Object.assign({}, options, { headers: headers }));
if (!res.ok) {
let detail = "";
try { const j = await res.json(); detail = j.error || j.reason || ""; } catch (e) {}
const err = new Error(detail || ("HTTP " + res.status));
err.status = res.status;
throw err;
}
return res.json();
}
function switchTab(name) {
state.tab = name;
["create", "board", "meetings", "people", "more",
"streams", "projects", "map", "profile"].forEach(function (t) {
var sec = document.getElementById("sec-" + t);
if (sec) sec.className = t === name ? "on" : "";
});
// Нижняя панель: настроечные экраны подсвечивают кнопку "Ещё".
var navName = ["streams", "projects", "map", "profile", "more"].indexOf(name) >= 0 ? "more" : name;
["create", "board", "meetings", "people", "more"].forEach(function (t) {
var b = document.getElementById("tab-" + t);
if (b) b.className = t === navName ? "on" : "";
});
// Из "Ещё" открыли настройку - наверх, чтобы не искать начало экрана.
if (["streams", "projects", "map", "profile"].indexOf(name) >= 0) window.scrollTo(0, 0);
}
// --------------------------- Загрузка данных ---------------------------
function enterDemo(reason) {
state.demo = true;
state.tasks = DEMO_TASKS;
state.projects = [{ id: "dp", name: "Сайт", legend: "Демо-проект", stream_id: "ds" }];
state.streams = [{ id: "ds", name: "Продукт" }];
state.meetings = [];
state.highlights = [];
state.channels = [];
state.me = null;
document.getElementById("sub").textContent = "демо-режим";
banner(reason, "warn");
}
async function load() {
// Боевой режим даёт либо вход из Telegram, либо токен рекордера в адресе
// (ссылка "открыть в браузере") - у обоих одинаковые права на сервере.
if (!initData && !recordToken) {
enterDemo("Демо-режим: данные ненастоящие. Откройте приложение из Telegram.");
renderAll();
return;
}
try {
// /me заодно фиксирует вход и, если IP новый, шлёт уведомление на почту.
const results = await Promise.all([
api("/api/tasks"),
api("/api/projects"),
api("/api/me"),
api("/api/channels"),
api("/api/jobs").catch(function () { return { jobs: [] }; }),
api("/api/map").catch(function () { return { highlights: [] }; }),
api("/api/streams").catch(function () { return { streams: [] }; }),
api("/api/meetings").catch(function () { return { meetings: [] }; }),
api("/api/org/settings").catch(function () { return { settings: null }; }),
api("/api/members").catch(function () { return { members: [] }; }),
api("/api/me/profile").catch(function () { return { profile: null }; }),
api("/api/rooms").catch(function () { return { rooms: [] }; }),
api("/api/people/all").catch(function () { return { people: [] }; }),
]);
state.tasks = results[0].tasks || [];
state.projects = results[1].projects || [];
state.me = results[2].me || null;
state.channels = results[3].channels || [];
// Расписания могут быть недоступны на старой версии сервера - не роняем
// из-за этого всё приложение, просто не показываем блок.
state.jobs = results[4].jobs || [];
state.highlights = results[5].highlights || [];
state.streams = results[6].streams || [];
state.meetings = results[7].meetings || [];
state.orgSettings = results[8].settings || null;
state.members = results[9].members || [];
state.myProfile = results[10].profile || null;
state.rooms = results[11].rooms || [];
state.allPeople = (results[12].people || []).sort(function (a, b) {
var la = (a.projects || []).length ? 1 : 0;
var lb = (b.projects || []).length ? 1 : 0;
return la - lb || String(a.name).localeCompare(String(b.name), "ru");
});
state.demo = false;
const who = state.me && state.me.displayName ? state.me.displayName : "вы";
var open = state.tasks.filter(function (t) {
return t.status === "backlog" || t.status === "in_progress";
});
var attention = state.tasks.filter(function (t) {
return t.ingest_status === "pending" || dueOverdue(t);
}).length;
document.getElementById("sub").textContent = who + " · открыто: " + open.length +
(attention ? " · требуют внимания: " + attention : "");
var bb = document.getElementById("board-badge");
if (bb) {
bb.style.display = attention > 0 ? "" : "none";
bb.textContent = attention > 99 ? "99+" : String(attention);
}
banner("");
} catch (e) {
if (e.status === 403) {
// Аутентифицирован в Telegram, но не заведён в рабочем пространстве.
state.tasks = []; state.projects = []; state.channels = []; state.jobs = []; state.me = null; state.demo = false;
document.getElementById("sub").textContent = "нет доступа";
banner("Вы ещё не подключены к рабочему пространству. Попросите лидера добавить вас.", "err");
renderAll();
return;
}
enterDemo("Бэкенд недоступен (" + e.message + "). Показаны демо-данные.");
}
renderAll();
}
// Мастер первых шагов. Шаг считается пройденным по ФАКТУ (есть стрим, есть
// проект, привязана группа, заведён человек, была встреча), а не по галочке -
// человек не должен отчитываться приложению о том, что оно и так видит.
// Просьба владельца 12.08: "нужен онбординг - раз, два, три, четыре, пять".
function renderOnboarding() {
var box = document.getElementById("onboarding");
if (!box) return;
var groups = (state.channels || []).filter(function (c) { return c.type !== "tg_dm"; });
var steps = [
{ t: "Завести стрим", d: "Направление работы: «Азартные игры», «Новый дом».",
done: (state.streams || []).length > 0, go: "streams" },
{ t: "Создать проект в стриме", d: "И написать легенду - по ней ИИ понимает, что здесь считать задачей.",
done: (state.projects || []).filter(function (p) { return p.kind !== "one_on_one"; }).length > 0, go: "projects" },
{ t: "Добавить людей", d: "Участники получают вход и уведомления; люди из чатов появляются сами.",
done: (state.allPeople || []).length > 0, go: "people" },
{ t: "Привязать рабочую группу", d: "Добавьте бота в группу Telegram, напишите туда любое сообщение и свяжите её с проектом.",
done: groups.some(function (c) { return !!c.project_name; }), go: "projects" },
{ t: "Завести первую задачу", d: "Голосом или текстом - на вкладке «+».",
done: (state.tasks || []).length > 0, go: "create" },
{ t: "Провести встречу", d: "Живая комната, запись звонка из браузера или загрузка файла.",
done: (state.meetings || []).length > 0, go: "meetings" },
];
var doneCount = steps.filter(function (x) { return x.done; }).length;
if (doneCount === steps.length) { box.innerHTML = ""; return; }
box.innerHTML = '<div class="card" style="border-color:var(--accent)">' +
'<div><b>С чего начать</b> <span class="tag">' + doneCount + " из " + steps.length + "</span></div>" +
'<div class="sub" style="margin:4px 0 8px">Пропадёт само, когда всё будет настроено.</div>' +
steps.map(function (st, i) {
return '<div class="row" style="margin-top:8px;align-items:flex-start">' +
'<span class="tag ' + (st.done ? "hi" : "") + '" style="margin-right:8px">' +
(st.done ? "готово" : String(i + 1)) + "</span>" +
'<div class="grow"><div style="font-weight:600">' + esc(st.t) + "</div>" +
'<div class="sub">' + esc(st.d) + "</div>" +
(st.done ? "" : '<button class="btn ghost sm" style="margin-top:4px" onclick="switchTab(\'' +
st.go + '\')">Перейти</button>') +
"</div></div>";
}).join("") + "</div>";
}
function renderAll() { render(); renderProjects(); renderMeetings(); renderRooms(); renderStreamsTab(); renderPeopleTab(); renderMap(); renderProfile(); renderJobs(); renderOnboarding(); }
// ------------------------------- Доска -------------------------------
/** Стрим проекта задачи: project_id задачи -> stream_id (или null). */
function taskStreamId(t) {
if (!t.project_id) return null;
var p = state.projects.filter(function (x) { return x.id === t.project_id; })[0];
return p ? (p.stream_id || null) : null;
}
function fillFilters() {
const fs2 = document.getElementById("f-stream");
const fp = document.getElementById("f-project");
const fa = document.getElementById("f-assignee");
const prevS = fs2.value, prevP = fp.value, prevA = fa.value;
// Стримы показываются в фильтре, только когда они вообще заведены, - иначе
// это пустой select, который лишь занимает место.
const hasStreams = state.streams.length > 0;
fs2.style.display = hasStreams ? "" : "none";
if (hasStreams) {
fs2.innerHTML = '<option value="all">Стрим: все</option>' +
state.streams.map(function (s) {
return '<option value="' + esc(s.id) + '">' + esc(s.name) + "</option>";
}).join("");
fs2.value = state.streams.some(function (s) { return s.id === prevS; }) ? prevS : "all";
}
// Проекты в фильтре сужаются выбранным стримом - две вложенные воронки.
const chosenStream = hasStreams ? fs2.value : "all";
const projects = [], people = [];
state.tasks.forEach(function (t) {
if (chosenStream === "all" || taskStreamId(t) === chosenStream) {
if (t.project_name && projects.indexOf(t.project_name) === -1) projects.push(t.project_name);
}
if (t.assignee_name && people.indexOf(t.assignee_name) === -1) people.push(t.assignee_name);
});
fp.innerHTML = '<option value="all">Проект: все</option>' +
projects.map(function (p) { return '<option value="' + esc(p) + '">' + esc(p) + "</option>"; }).join("");
fa.innerHTML = '<option value="all">Исполнитель: все</option>' +
people.map(function (p) { return '<option value="' + esc(p) + '">' + esc(p) + "</option>"; }).join("");
// Возвращаем прежний выбор, если он всё ещё существует.
fp.value = projects.indexOf(prevP) !== -1 ? prevP : "all";
fa.value = people.indexOf(prevA) !== -1 ? prevA : "all";
}
function visibleTasks() {
const fsel = document.getElementById("f-stream");
const fst = fsel.style.display === "none" ? "all" : fsel.value;
const fp = document.getElementById("f-project").value;
const fa = document.getElementById("f-assignee").value;
// Поиск - по названию, описанию, исполнителю и проекту, без учёта регистра.
var q = (document.getElementById("f-search").value || "").trim().toLowerCase();
var list = state.tasks.filter(function (t) {
if (fst !== "all" && taskStreamId(t) !== fst) return false;
if (fp !== "all" && t.project_name !== fp) return false;
if (fa !== "all" && t.assignee_name !== fa) return false;
var fs = document.getElementById("f-status").value;
if (fs !== "all" && t.status !== fs) return false;
if (q) {
var hay = ((t.title || "") + " " + (t.description || "") + " " +
(t.assignee_name || "") + " " + (t.project_name || "")).toLowerCase();
if (hay.indexOf(q) === -1) return false;
}
return true;
});
// Сортировка - по решению владельца: новые/старые по дате заведения,
// либо по сроку (горящие сверху, задачи без срока в конце).
var sort = document.getElementById("f-sort").value;
if (sort === "old") {
list = list.slice().sort(function (a, b) { return new Date(a.created_at) - new Date(b.created_at); });
} else if (sort === "due") {
list = list.slice().sort(function (a, b) {
if (!a.due && !b.due) return 0;
if (!a.due) return 1;
if (!b.due) return -1;
return a.due < b.due ? -1 : a.due > b.due ? 1 : 0;
});
}
return list;
}
// Сколько фильтров активно - видно на свёрнутой гармошке; там же сброс.
function refreshFilterBadge() {
var n = 0;
var fs2 = document.getElementById("f-stream");
if (fs2.style.display !== "none" && fs2.value !== "all") n += 1;
if (document.getElementById("f-project").value !== "all") n += 1;
if (document.getElementById("f-assignee").value !== "all") n += 1;
if (document.getElementById("f-status").value !== "all") n += 1;
if (document.getElementById("f-sort").value !== "new") n += 1;
if ((document.getElementById("f-search").value || "").trim() !== "") n += 1;
var el = document.getElementById("f-active");
if (el) el.textContent = n ? "· включено: " + n : "· всё показано";
return n;
}
function resetFilters() {
["f-project", "f-assignee", "f-status"].forEach(function (id) {
document.getElementById(id).value = "all";
});
var fs2 = document.getElementById("f-stream");
if (fs2.style.display !== "none") fs2.value = "all";
document.getElementById("f-sort").value = "new";
document.getElementById("f-search").value = "";
state.cameFrom = null;
render();
}
function confidenceTag(c) {
if (!c || c === "high") return "";
// Коротко: "ИИ сомневается" - и всё ("напиши просто", разбор 11.08).
// Разница med/low остаётся в подсказке и цвете, на карточке не шумит.
const cls = c === "low" ? "lo" : "warn";
const tip = c === "low"
? "ИИ не уверен, что это задача - откройте и подтвердите или отклоните"
: "Задача боевая, но исполнитель или срок под вопросом - загляните";
return '<span class="tag ' + cls + '" title="' + tip + '">ИИ сомневается</span>';
}
function sourceKindLabel(t) {
// Словарь источников - по разбору владельца: "продумай, какие есть".
if (t.source_channel_type === "meeting" || t.source_meeting_kind) {
if (t.source_meeting_kind === "room") return "офлайн-встреча";
if (t.source_meeting_kind === "zoom") return "Zoom-встреча";
return "встреча (запись)";
}
if (t.source_channel_type === "tg_dm") return "ТГ личка";
if (t.source_channel_type === "tg_group") return "ТГ группа";
var ref = t.source_ref || "";
if (ref === "надиктовано") return "надиктовано";
if (ref === "создано вручную") return "вручную";
if (ref.indexOf("советник") !== -1) return "советник";
if (ref.indexOf("разбор") !== -1) return "разбор недели";
if (ref.indexOf("встреч") !== -1) return "встреча";
return null;
}
function sourceLineHtml(t) {
var kind = sourceKindLabel(t);
if (!kind && !t.source_ref) return "";
var who = "";
if (t.source_contact) {
who = esc(t.source_contact);
if (t.source_username) {
who += ' <a href="https://t.me/' + esc(t.source_username) + '" target="_blank">@' +
esc(t.source_username) + "</a>";
}
}
var text;
var meetingLink = "";
if (t.source_meeting_kind) {
var mk = t.source_meeting_kind === "room" ? "офлайн-встреча"
: t.source_meeting_kind === "zoom" ? "Zoom-встреча" : "встреча (запись)";
text = mk + (t.source_meeting_title ? " «" + esc(t.source_meeting_title) + "»" : "") +
(t.source_date ? ", " + esc(t.source_date) : "");
// Ссылка на встречу-источник (разбор 12.08): транскрипт открывается прямо
// в карточке - видно место, из которого задача родилась.
if (t.source_meeting_id) {
meetingLink = '<div class="acts" style="margin-top:6px"><button class="btn ghost sm" ' +
'onclick="toggleCardTranscript(\'' + esc(t.source_meeting_id) + '\')">Транскрипт встречи</button></div>' +
'<div id="card-mtr"></div>';
}
} else if (t.source_channel_type === "tg_dm") {
text = "переписка в Telegram с " + (who || "контактом") + (t.source_date ? " от " + esc(t.source_date) : "");
} else if (t.source_channel_type === "tg_group") {
text = "ТГ группа «" + (who || "…") + "»" + (t.source_date ? ", " + esc(t.source_date) : "");
} else if (t.source_ref === "надиктовано" || t.source_ref === "создано вручную") {
text = (t.source_ref === "надиктовано" ? "надиктовано" : "создано вручную") +
(t.author_name ? " - " + esc(t.author_name) : "");
} else if (t.source_ref) {
text = esc(t.source_ref);
} else {
text = kind || "";
}
return '<div class="sub" style="margin-top:6px">Источник: ' + text + "</div>" + meetingLink;
}
// Транскрипт встречи-источника прямо в карточке задачи: повторное нажатие
// сворачивает. Тот же приём, что у списка встреч (toggleTranscript).
async function toggleCardTranscript(meetingId) {
var box = document.getElementById("card-mtr");
if (!box) return;
if (box.innerHTML) { box.innerHTML = ""; return; }
box.innerHTML = '<div class="sub">загружаю…</div>';
try {
var data = await api("/api/meetings/" + encodeURIComponent(meetingId) + "/transcript");
box.innerHTML = (data.chunks || []).map(function (c) {
var t = c.offsetSec === null ? "" : "<b>" + esc(fmtOffset(c.offsetSec)) + "</b> ";
return '<div class="sub" style="margin-top:6px">' + t + esc(c.text) + "</div>";
}).join("") || '<div class="sub">транскрипт пуст</div>';
} catch (e) {
box.innerHTML = '<div class="sub">не загрузился: ' + esc(e.message) + "</div>";
}
}
/** Срок в человеческом виде: 2026-08-10 -> 10.08 (год не пишем - и так ясно). */
function fmtDue(due) {
if (!due) return "";
var m = String(due).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return String(due);
var yearNow = String(new Date().getFullYear());
return m[3] + "." + m[2] + (m[1] === yearNow ? "" : "." + m[1].slice(2));
}
/** Дата чека прошла, а задача всё ещё открыта. Сравнение строк YYYY-MM-DD. */
function dueOverdue(t) {
if (!t.due) return false;
if (t.status === "done" || t.status === "cancelled") return false;
var now = new Date();
var today = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0") +
"-" + String(now.getDate()).padStart(2, "0");
return t.due < today;
}
function taskHtml(t) {
// pending — ИИ не уверен, задача ждёт допуска человека.
// fromAi — задача извлечена из переписки (у ручных extraction_confidence пуст).
const pending = t.ingest_status === "pending";
const fromAi = !!t.extraction_confidence;
const meta = [];
// Порядок строк - по разбору владельца: стрим и проект; ниже исполнитель,
// источник и срок; ниже служебное. Разрывы - пустые элементы на всю ширину.
if (t.stream_name) meta.push('<span class="tag">' + esc(t.stream_name) + "</span>");
if (t.project_name) meta.push('<span class="tag">' + esc(t.project_name) + "</span>");
meta.push('<i class="meta-break"></i>');
var srcKind = sourceKindLabel(t);
// Один бейдж исполнителя, без дублей: имя, либо жёлтое "нужен исполнитель"
// (для задач от ИИ), либо серое "без исполнителя" у ручных.
if (t.assignee_name) {
meta.push('<span class="tag">' + esc(t.assignee_name) + "</span>");
} else if (!(fromAi && t.status !== "cancelled")) {
meta.push('<span class="tag">без исполнителя</span>');
}
if (srcKind) meta.push('<span class="tag">из: ' + srcKind + "</span>");
if (t.due) {
// Просроченный срок не кричит словами - просто красная заливка,
// "и так понятно, что красное значит просрочка" (владелец, 11.08).
var over = dueOverdue(t);
meta.push('<span class="tag' + (over ? " due-over" : "") +
'" title="' + (over ? "Срок прошёл, задача открыта" : "Срок: когда спросим статус") +
'">срок: ' + fmtDue(t.due) + "</span>");
}
meta.push('<i class="meta-break"></i>');
if ((t.status === "backlog" || t.status === "in_progress") && Number(t.idle_days) >= 3) {
meta.push('<span class="tag warn">без движения ' + Number(t.idle_days) + ' дн</span>');
}
if (pending) meta.push('<span class="tag warn">ждёт подтверждения</span>');
// Средняя уверенность: задача боевая, но исполнитель или срок под вопросом.
// Без пометки такая задача выглядит разобранной, и её неполнота вскроется,
// когда срок уже прошёл.
if (!pending && t.extraction_confidence === "med") {
meta.push('<span class="tag warn">уточнить детали</span>');
}
// Исполнителя не угадываем принципиально, поэтому пустое поле бывает и у
// уверенной задачи. Пометка нужна, чтобы её было видно, а не терялось.
if (fromAi && !t.assignee_name && t.status !== "cancelled") {
meta.push('<span class="tag warn">нужен исполнитель</span>');
}
// "ИИ уверен" на каждой карточке - шум ("а если не уверен, то что?").
// Показываем уверенность только когда она НЕ высокая - когда есть повод
// глянуть; уверенная задача помечена самим отсутствием пометок.
if (t.extraction_confidence && t.extraction_confidence !== "high") {
meta.push(confidenceTag(t.extraction_confidence));
}
// Последний комментарий - живой хвост обсуждения прямо на доске.
var lastc = "";
if (t.last_comment) {
lastc = '<div class="hint">' + esc(t.last_comment_author || "") +
(t.last_comment_at ? " · " + esc(shortDate(t.last_comment_at)) : "") + ": " +
esc(String(t.last_comment).slice(0, 120)) +
(String(t.last_comment).length > 120 ? "…" : "") + "</div>";
}
// Обработчики НЕ пишем инлайном с подстановкой данных: id и статус кладём в
// data-атрибуты, а клики ловит один делегированный слушатель (см. ниже).
// Так данные никогда не попадают в исполняемый код — целый класс XSS исключён.
const id = esc(t.id);
const acts = (NEXT[t.status] || []).filter(function (pair) {
// У задач от ИИ вместо обезличенного «Отменить» показываем «Это не задача»:
// так честнее — мы правим ошибку распознавания, а не закрываем работу.
return !(fromAi && pair[0] === "cancelled");
}).map(function (pair) {
return '<button class="btn ghost sm" data-act="status" data-id="' + id +
'" data-status="' + pair[0] + '">' + pair[1] + "</button>";
});
if (pending) {
acts.unshift('<button class="btn sm" data-act="confirm" data-id="' + id +
'" title="ИИ не уверен, что это задача. Подтвердите - и она станет обычной.">Подтвердить</button>');
}
if (fromAi && t.status !== "cancelled") {
acts.push('<button class="btn ghost sm" data-act="reject" data-id="' + id +
'" title="ИИ ошибся: убрать эту задачу с доски">Это не задача</button>');
}
return '<div class="task" data-act="open" data-id="' + id + '">' +
'<div class="title" data-act="open" data-id="' + id + '" role="button" tabindex="0">' +
esc(t.title) + "</div>" +
'<div class="meta">' + meta.join("") + "</div>" +
(t.source_quote ? '<div class="quote">«' + esc(t.source_quote) + "»</div>" : "") +
lastc +
(pending ? '<div class="hint">ИИ не уверен, что это задача - проверьте и подтвердите.</div>' : "") +
'<div class="acts">' + acts.join("") + "</div>" +
"</div>";
}
// Один слушатель на всю доску: находим ближайшую кнопку с data-act и работаем
// с её data-атрибутами. Кнопки перерисовываются свободно — слушатель не теряется.
document.getElementById("board").addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-act]");
if (!btn) return;
const id = btn.getAttribute("data-id");
const act = btn.getAttribute("data-act");
if (act === "confirm") confirmTask(id);
else if (act === "reject") rejectTask(id);
else if (act === "open") openTask(id);
else setStatus(id, btn.getAttribute("data-status"));
});
function render() {
fillFilters();
refreshFilterBadge();
const tasks = visibleTasks();
// Пришли сюда кнопкой с карточки человека или стрима - даём дорогу назад.
var backChip = state.cameFrom
? '<div style="margin-bottom:8px"><button class="btn ghost sm" onclick="goBackFrom()">← назад: ' +
(state.cameFrom === "people" ? "Люди" : "Стримы") + "</button></div>"
: "";
// Каждая колонка — самостоятельный блок. На телефоне блоки идут друг под
// другом, на широком экране раскладываются в ряд (см. media-запрос в стилях).
document.getElementById("board").innerHTML = backChip + COLUMNS.map(function (col) {
const list = tasks.filter(function (t) { return t.status === col.key; });
return '<div class="col">' +
'<div class="col-head"><span class="dot" style="background:' + col.color + '"></span>' +
'<span class="col-title">' + col.label + "</span>" +
'<span class="count">' + list.length + "</span></div>" +
'<div class="col-body">' +
(list.length ? list.map(taskHtml).join("") : '<div class="empty">пусто</div>') +
"</div></div>";
}).join("");
}
// Предпросмотр надиктовки: разбор показывается ДО сохранения ("не так, что
// сразу улетела"). Сохранить - отдельной кнопкой; сохраняется РОВНО то, что
// показано, модель второй раз не спрашивается.
var dictPreview = null;
async function previewDictate(mode) {
var input = document.getElementById("new-title");
var text = input.value.trim();
if (!text) return;
var projName = document.getElementById("f-project").value;
var proj = state.projects.filter(function (p) { return p.name === projName; })[0];
banner("Разбираю…");
try {
var r = await api("/api/tasks/dictate", {
method: "POST",
body: JSON.stringify({
text: text, projectId: proj ? proj.id : null,
mode: mode, voice: state.dictVoice === true, preview: true,
}),
});
dictPreview = { fields: r.fields, projectId: proj ? proj.id : null, raw: text, mode: mode };
renderDictPreview();
document.getElementById("dict-preview").style.display = "";
banner("Проверьте разбор и нажмите Сохранить", "ok");
} catch (e) { banner("Не разобралось: " + e.message, "err"); }
}
// Чек-лист разбора: по строке на поле задачи, зелёная галочка у распознанного,
// жёлтое "не назван" у пропущенного - видно, что додиктовать. Идея владельца:
// "надиктовал - он чек-лист: что, кто, когда, стрим-проект - и ты галочки видишь".
function renderDictPreview() {
var f = dictPreview.fields;
function row(label, value, missingText) {
var ok = value !== null && value !== undefined && String(value).trim() !== "";
return '<div class="row" style="margin-top:6px"><span class="tag ' + (ok ? "hi" : "warn") +
'">' + (ok ? "есть" : "нет") + '</span><div class="grow" style="margin-left:8px">' +
"<b>" + label + ":</b> " + (ok ? esc(String(value)) : '<span class="sub">' + missingText + "</span>") +
"</div></div>";
}
// Проект: выбранный руками (фильтр доски) главнее; иначе - предложение
// модели по тексту (стрим, имя проекта или тема легенды). Разбор 12.08:
// "проект и стрим он не предложил - надо, чтобы предлагал".
var chosenProjId = dictPreview.projectId || f.suggestedProjectId || "";
var projLabel = null, projMiss = "не назван - выберите ниже";
if (dictPreview.projectId) {
var chosen = state.projects.filter(function (p) { return p.id === dictPreview.projectId; })[0];
projLabel = chosen ? chosen.name : null;
} else if (f.suggestedProjectId) {
projLabel = (f.suggestedProjectName || "") +
(f.suggestedStreamName ? " · стрим " + f.suggestedStreamName : "") +
" - предложил по тексту, проверьте";
}
var rows = [
row("Что сделать", f.title, "не разобралось"),
row("Описание", f.description, "пусто - можно додиктовать"),
row("Кто", f.assignee, f.assigneeMissing
? "«" + esc(f.assigneeMissing) + "» не найден в картотеке - заведите на вкладке Люди или скажите иначе"
: "не назван - можно додиктовать или выбрать в карточке после"),
row("Когда", f.due ? fmtDue(f.due) : null, "срок не назван"),
row("Проект", projLabel, projMiss),
];
if (f.rawVoice) {
rows.push('<div class="sub" style="margin-top:6px">К описанию добавится исходная надиктовка: «' +
esc(f.rawVoice) + "»</div>");
}
document.getElementById("dp-rows").innerHTML = rows.join("");
// Проект - выбирается прямо здесь; по умолчанию ручной выбор или предложение.
var sel = document.getElementById("dp-project");
sel.innerHTML = '<option value="">без проекта</option>' + state.projects.map(function (p) {
return '<option value="' + esc(p.id) + '"' + (p.id === chosenProjId ? " selected" : "") +
">" + esc(p.name) + "</option>";
}).join("");
}
function cancelDictPreview() {
dictPreview = null;
document.getElementById("dict-preview").style.display = "none";
}
async function saveDictPreview() {
if (!dictPreview) return;
try {
var projSel = document.getElementById("dp-project");
await api("/api/tasks/dictate", {
method: "POST",
body: JSON.stringify({
text: dictPreview.raw,
projectId: (projSel && projSel.value) || dictPreview.projectId,
mode: dictPreview.mode,
voice: state.dictVoice === true,
confirmed: dictPreview.fields,
}),
});
document.getElementById("new-title").value = "";
state.dictVoice = false;
cancelDictPreview();
var mrow = document.getElementById("dict-mode");
if (mrow) mrow.style.display = "none";
banner("Задача создана", "ok");
await load();
} catch (e) { banner("Не сохранилось: " + e.message, "err"); }
}
async function createTask(mode) {
const input = document.getElementById("new-title");
const title = input.value.trim();
if (!title) return;
var modeRow = document.getElementById("dict-mode");
if (state.demo) {
state.tasks.unshift({ id: "d" + Date.now(), title: title, status: "backlog",
assignee_name: null, project_name: "Сайт", ingest_status: "sent" });
input.value = ""; render(); return;
}
try {
// Умное создание: модель раскладывает текст в поля (название, описание,
// исполнитель из людей проекта, срок). Проект берём из фильтра доски.
var projName = document.getElementById("f-project").value;
var proj = state.projects.filter(function (p) { return p.name === projName; })[0];
var r = await api("/api/tasks/dictate", {
method: "POST",
body: JSON.stringify({
text: title,
projectId: proj ? proj.id : null,
mode: mode === "verbatim" ? "verbatim" : "smart",
voice: state.dictVoice === true,
}),
});
input.value = "";
state.dictVoice = false;
if (modeRow) modeRow.style.display = "none";
var f = (r && r.fields) || {};
banner("Задача создана: " + (f.title || title) +
(f.assignee ? " · исполнитель " + f.assignee : "") +
(f.due ? " · до " + f.due : "") +
(f.suggestedProjectName ? " · проект " + f.suggestedProjectName + " (по тексту)" : ""), "ok");
await load();
} catch (e) { banner("Не удалось создать задачу: " + e.message, "err"); }
}
async function setStatus(id, status) {
if (state.demo) {
const t = state.tasks.filter(function (x) { return x.id === id; })[0];
if (t) t.status = status;
render(); return;
}
try {
await api("/api/tasks/" + encodeURIComponent(id), { method: "PATCH", body: JSON.stringify({ status: status }) });
await load();
} catch (e) { banner("Не удалось сменить статус: " + e.message, "err"); }
}
async function confirmTask(id) {
if (state.demo) {
const t = state.tasks.filter(function (x) { return x.id === id; })[0];
if (t) t.ingest_status = "sent";
render(); return;
}
try {
await api("/api/tasks/" + encodeURIComponent(id) + "/confirm", { method: "POST" });
await load();
} catch (e) { banner("Не удалось подтвердить: " + e.message, "err"); }
}
// «Это не задача»: ИИ ошибся. Задача уходит в «Отменено» и помечается как
// отклонённая — след остаётся, чтобы видеть, на чём модель спотыкается.
async function rejectTask(id) {
if (state.demo) {
const t = state.tasks.filter(function (x) { return x.id === id; })[0];
if (t) { t.status = "cancelled"; t.ingest_status = "skipped"; }
render(); return;
}
try {
await api("/api/tasks/" + encodeURIComponent(id) + "/reject", { method: "POST" });
await load();
} catch (e) { banner("Не удалось отклонить: " + e.message, "err"); }
}
// --------------------------- Карточка задачи ---------------------------
// Здесь задача перестаёт быть строкой на доске и становится тем, с чем можно
// работать: поправить формулировку, дописать подробности, обсудить, приложить
// файл, посмотреть, откуда она вообще взялась.
/** Карточка для демо-режима — собирается из того, что уже есть на доске. */
function demoCard(id) {
const t = state.tasks.filter(function (x) { return x.id === id; })[0];
if (!t) return null;
return {
task: {
id: t.id, title: t.title, description: t.description || null, status: t.status,
project_name: t.project_name, assignee_name: t.assignee_name, due: t.due,
ingest_status: t.ingest_status, extraction_confidence: t.extraction_confidence,
source_quote: t.source_quote, resolution: null, duplicate_of_title: null,
},
comments: t.comments || [],
attachments: t.attachments || [],
history: [],
};
}
async function openTask(id) {
const view = document.getElementById("taskview");
view.className = "";
view.innerHTML = '<div class="tv-body"><div class="empty">Открываю…</div></div>';
try {
const card = state.demo ? demoCard(id) : await api("/api/tasks/" + encodeURIComponent(id));
if (!card) throw new Error("не найдена");
// Люди проекта - для выбора исполнителя (включая незарегистрированных).
card.people = [];
if (!state.demo && card.task && card.task.project_id) {
try {
var pr = await api("/api/projects/" + encodeURIComponent(card.task.project_id) + "/people");
card.people = pr.people || [];
} catch (e2) { /* без списка людей карточка всё равно живёт */ }
}
state.card = card;
renderTaskView();
} catch (e) {
view.innerHTML = '<div class="tv-body"><div class="banner err">Не удалось открыть: ' +
esc(e.message) + '</div><button class="btn ghost" data-tv="close">Назад</button></div>';
}
}
function closeTaskView() {
document.getElementById("taskview").className = "hidden";
state.card = null;
}
/** Варианты исполнителя: люди проекта, зарегистрированные и нет. */
/** Текущий исполнитель карточки в виде значения option: "u:id" | "p:id" | "". */
function assigneeValue(t) {
if (t.assignee_user_id) return "u:" + t.assignee_user_id;
if (t.assignee_person_id) return "p:" + t.assignee_person_id;
return "";
}
function assigneeOptions(c) {
var t = c.task;
var curVal = assigneeValue(t);
var cur = t.assignee_name || "";
var opts = ['<option value="">без исполнителя</option>'];
var matched = false;
// Сначала точное совпадение по идентификатору, имя - только как запасной
// вариант: одинаковые имена в списке иначе подсвечивали не того человека.
function selAttr(v, name) {
if (matched) return "";
var hit = curVal ? v === curVal : (cur !== "" && name === cur);
if (hit) matched = true;
return hit ? " selected" : "";
}
(c.people || []).forEach(function (p) {
var v = p.user_id ? "u:" + p.user_id : "p:" + p.id;
var name = p.display_name || "человек";
opts.push('<option value="' + esc(v) + '"' + selAttr(v, name) + ">" + esc(name) +
(p.in_tracker ? "" : p.discovered_via === "manual" ? " (без входа в todoiter)" : " (из чата)") +
"</option>");
});
// Всё пространство: люди других проектов и участники - твой "а где Пётр-то?".
var inProj = {};
(c.people || []).forEach(function (p) { inProj[(p.display_name || "").toLowerCase()] = true; });
var extra = (state.allPeople || []).filter(function (h) {
return !inProj[(h.name || "").toLowerCase()];
});
if (extra.length) {
opts.push('<optgroup label="Всё пространство">');
extra.forEach(function (h) {
var v = h.userId ? "u:" + h.userId : (h.personIds && h.personIds[0] ? "p:" + h.personIds[0] : "");
if (!v) return;
// Человек из другого проекта только что назначен - подсветка обязана
// остаться на нём, иначе выбор "куда-то пропадает" (разбор 10.08).
var sel = !matched && h.userId && curVal === "u:" + h.userId ? " selected" : "";
if (!sel && !matched && curVal.indexOf("p:") === 0 && (h.personIds || []).indexOf(curVal.slice(2)) >= 0) sel = " selected";
if (sel) matched = true;
opts.push('<option value="' + esc(v) + '"' + sel + ">" + esc(h.name) + "</option>");
});
opts.push("</optgroup>");
}
opts.push('<option value="new">+ Вписать имя…</option>');
// Текущий исполнитель не нашёлся ни в одном списке - показываем как есть.
if (cur && !matched) opts.push('<option value="' + esc(curVal) + '" selected>' + esc(cur) + "</option>");
return opts.join("");
}
function projectOptions(t, streamId) {
var opts = ['<option value="">без проекта</option>'];
state.projects.forEach(function (p) {
// Выбран стрим - показываем только его проекты (плюс текущий, чтобы не
// потерять выбор из другого стрима).
if (streamId && p.stream_id !== streamId && p.id !== t.project_id) return;
opts.push('<option value="' + esc(p.id) + '"' + (p.id === t.project_id ? " selected" : "") + ">" +
esc(p.name) + "</option>");
});
return opts.join("");
}
/** Стримы для карточки: текущий - по проекту задачи. */
function cardStreamOptions(t) {
var cur = null;
state.projects.forEach(function (p) { if (p.id === t.project_id) cur = p.stream_id; });
var opts = ['<option value="">все стримы</option>'];
state.streams.forEach(function (st) {
opts.push('<option value="' + esc(st.id) + '"' + (st.id === cur ? " selected" : "") + ">" +
esc(st.name) + "</option>");
});
return opts.join("");
}
// Смена стрима в карточке сама ничего не сохраняет - она пересобирает список
// проектов; сохраняется именно проект (стрим - его атрибут).
function cardStreamChanged() {
var c = state.card;
if (!c) return;
var sid = document.getElementById("tv-stream").value || null;
document.getElementById("tv-project").innerHTML = projectOptions(c.task, sid);
markCardDirty();
}
// Подбор из картотеки: контакты Telegram ботам недоступны в принципе,
// поэтому подсказываем из СВОЕЙ картотеки людей (вкладка Люди).
function peopleDatalistHtml() {
var opts = (state.allPeople || []).map(function (h) {
return '<option value="' + esc(h.name + (h.username ? " @" + h.username : "")) + '"></option>';
}).join("");
return '<datalist id="people-names">' + opts + "</datalist>";
}
function attachmentsHtml(c) {
var list = c.attachments || [];
if (!list.length) return '<div class="sub">пока нет - приложите файл (до 20 МБ) или киньте ссылку в комментарий</div>';
return list.map(function (a) {
var size = a.size_bytes ? " · " + Math.max(1, Math.round(a.size_bytes / 1024)) + " КБ" : "";
if (a.has_file) {
return '<div class="sub" style="margin-top:4px"><a href="#" onclick="downloadAttachment(\'' +
esc(a.id) + '\', \'' + esc(a.name || "файл") + '\');return false">' +
esc(a.name || "файл") + "</a>" + size + "</div>";
}
return '<div class="sub" style="margin-top:4px"><a href="' + esc(a.url || "#") +
'" target="_blank">' + esc(a.name || a.url || "ссылка") + "</a>" + size + "</div>";
}).join("");
}
// Скачивание с заголовком авторизации: обычная ссылка initData не передаст.
async function downloadAttachment(id, name) {
try {
var res = await fetch(API_BASE + "/api/attachments/" + encodeURIComponent(id) + "/download", {
headers: authHeaders(),
});
if (!res.ok) throw new Error("HTTP " + res.status);
var blob = await res.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url; a.download = name || "file";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
} catch (e) { banner("Не скачалось: " + e.message, "err"); }
}
async function uploadTaskFile() {
var c = state.card;
if (!c) return;
var inp = document.getElementById("tv-file");
var f = inp && inp.files && inp.files[0];
if (!f) { banner("Выберите файл", "warn"); return; }
if (f.size > 20 * 1024 * 1024) { banner("Файл больше 20 МБ - потолок вложений", "warn"); return; }
try {
var res = await fetch(API_BASE + "/api/tasks/" + encodeURIComponent(c.task.id) + "/files", {
method: "POST",
headers: Object.assign(authHeaders(), {
"Content-Type": f.type || "application/octet-stream",
"X-File-Name": encodeURIComponent(f.name),
}),
body: f,
});
var data = await res.json();
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
banner("Файл приложен", "ok");
await openTask(c.task.id);
} catch (e) { banner("Не приложился: " + e.message, "err"); }
}
async function createAndAssignPerson() {
var c = state.card;
if (!c || !c.task.project_id) { banner("Сначала выберите проект задачи", "warn"); return; }
var name = (document.getElementById("tv-newperson-name").value || "").trim();
if (!name) return;
try {
var r = await api("/api/projects/" + encodeURIComponent(c.task.project_id) + "/people", {
method: "POST", body: JSON.stringify({ displayName: name }),
});
var pid = r && r.person && r.person.id;
if (!pid) throw new Error("человек не создался");
await api("/api/tasks/" + encodeURIComponent(c.task.id) + "/assign", {
method: "PATCH", body: JSON.stringify({ assigneePersonId: pid }),
});
banner("Назначено: " + name, "ok");
await load();
await openTask(c.task.id);
} catch (e) { banner("Не получилось: " + e.message, "err"); }
}
// Выбор в списках больше НЕ сохраняет сам по себе: владельца удивило, что
// исполнитель "сразу пророс, хотя сохранить не нажал" (разбор 10.08).
// Теперь список только помечает несохранённое, а применяет всё кнопка
// Сохранить - одна на название, описание, проект и исполнителя.
function assigneeChanged() {
var v = document.getElementById("tv-assignee").value;
var row = document.getElementById("tv-newperson");
if (row) row.style.display = v === "new" ? "" : "none";
if (v === "new" && row) {
// Поле было ниже края экрана - человек выбирал "Вписать имя" и не видел,
// куда вписывать ("никакая клавиатура не появляется", разбор 11.08).
row.scrollIntoView({ block: "center", behavior: "smooth" });
var inp = document.getElementById("tv-newperson-name");
if (inp) setTimeout(function () { inp.focus(); }, 250);
}
if (v !== "new") markCardDirty();
}
// Поле растёт под текст, но не больше двух своих ростов - иначе карточка
// превращается в простыню (решение владельца 12.08).
// Поле, которое надо заполнить: красная рамка на нём самом плюс подсказка.
// Рамка снимается, как только человек начал печатать.
function needField(id, why) {
var el = document.getElementById(id);
if (!el) { banner(why, "warn"); return; }
el.classList.add("need");
el.scrollIntoView({ block: "center", behavior: "smooth" });
el.focus();
banner(why, "warn");
el.addEventListener("input", function once() {
el.classList.remove("need");
el.removeEventListener("input", once);
});
}
function autoGrow(el) {
if (!el) return;
var base = el.dataset.baseH ? Number(el.dataset.baseH) : 0;
if (!base) { base = el.clientHeight || 120; el.dataset.baseH = String(base); }
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight + 2, base * 2) + "px";
}
function markCardDirty() {
var n = document.getElementById("tv-dirty");
if (n) n.style.display = "";
}
/** Ссылки в тексте комментария делаем кликабельными - ПОСЛЕ экранирования. */
function linkify(escaped) {
return escaped.replace(/(https?:\/\/[^\s<]+)/g, function (u) {
return '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' + u + "</a>";
});
}
/** Комментарии: свежие сверху, ответы группируются под своим комментарием. */
function commentsHtml(list) {
// Порядок по решению владельца: свежие сверху; переключатель меняет.
list = list.slice().sort(function (a, b) {
var d = new Date(a.created_at) - new Date(b.created_at);
return state.commentOrder === "old" ? d : -d;
});
const roots = list.filter(function (c) { return !c.parent_comment_id; });
const kids = {};
list.forEach(function (c) {
if (!c.parent_comment_id) return;
(kids[c.parent_comment_id] = kids[c.parent_comment_id] || []).push(c);
});
function one(c, isReply) {
const src = c.source && c.source !== "manual"
? ' · <span class="tag">' + esc(sourceLabel(c.source, c.external_system)) + "</span>" : "";
const replies = (kids[c.id] || []).slice().reverse().map(function (k) { return one(k, true); }).join("");
// Ответить можно на корневой комментарий; глубже одного уровня не
// вкладываемся - в узкой карточке лесенка тредов нечитаема.
const replyBtn = isReply ? "" :
' · <a href="#" class="sub" data-reply="' + esc(c.id) + '">ответить</a>';
const replyBox = isReply ? "" :
'<div id="reply-' + esc(c.id) + '" style="display:none;margin-top:6px">' +
'<textarea id="reply-text-' + esc(c.id) + '" rows="2" placeholder="Ответ на этот комментарий…"></textarea>' +
'<div class="acts"><button class="btn sm" data-replysend="' + esc(c.id) + '">Ответить</button>' +
'<button class="btn sm ghost" data-replycancel="' + esc(c.id) + '">Отмена</button></div>' +
"</div>";
return '<div class="cmt"><div class="who">' + esc(c.author_name || "система") +
" · " + esc(shortDate(c.created_at)) + src + replyBtn + "</div>" +
'<div class="body">' + linkify(esc(c.body)) + "</div>" + replyBox +
(replies ? '<div class="replies">' + replies + "</div>" : "") + "</div>";
}
return roots.length ? roots.map(function (c) { return one(c, false); }).join("")
: '<div class="empty">Комментариев пока нет.</div>';
}
function toggleReply(id, show) {
var box = document.getElementById("reply-" + id);
if (box) box.style.display = show ? "" : "none";
if (show) {
var ta = document.getElementById("reply-text-" + id);
if (ta) ta.focus();
}
}
async function sendReply(id) {
var c = state.card;
if (!c) return;
var ta = document.getElementById("reply-text-" + id);
var text = (ta && ta.value || "").trim();
if (text === "") { banner("Напишите текст ответа - поле пустое.", "warn"); return; }
if (state.demo) { banner("В демо-режиме недоступно", "warn"); return; }
try {
await api("/api/tasks/" + encodeURIComponent(c.task.id) + "/comments", {
method: "POST", body: JSON.stringify({ body: text, parentCommentId: id }),
});
banner("Ответ отправлен", "ok");
await openTask(c.task.id);
} catch (e) { banner("Не удалось ответить: " + e.message, "err"); }
}
/**
* Обезвредить адрес перед подстановкой в href.
* Сервер уже проверяет ссылку при сохранении, но данные могут прийти и другим
* путём — например, из внешнего трекера через обмен. Схемы javascript: и data:
* выполняются по клику, поэтому пропускаем только http и https.
*/
function safeUrl(u) {
const s = String(u == null ? "" : u).trim();
return /^https?:\/\//i.test(s) ? s : "#";
}
function sourceLabel(source, system) {
if (source === "chat") return "из чата";
if (source === "meeting") return "со встречи";
if (source === "voice") return "голосом";
if (source === "external") return system ? "из " + system : "из внешней системы";
return "вручную";
}
function shortDate(iso) {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return String(iso);
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long" }) +
", " + d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
function renderTaskView() {
const c = state.card;
if (!c) return;
const t = c.task;
const meta = [];
if (t.project_name) meta.push('<span class="tag">' + esc(t.project_name) + "</span>");
meta.push('<span class="tag">' + (t.assignee_name ? esc(t.assignee_name) : "без исполнителя") + "</span>");
if (t.due) meta.push('<span class="tag' + (dueOverdue(t) ? " due-over" : "") + '">срок: ' +
fmtDue(t.due) + "</span>");
if (t.ingest_status === "pending") meta.push('<span class="tag warn">ждёт подтверждения</span>');
if (t.extraction_confidence && t.extraction_confidence !== "high") {
meta.push(confidenceTag(t.extraction_confidence));
}
if (t.duplicate_of_title) meta.push('<span class="tag">дубль: ' + esc(t.duplicate_of_title) + "</span>");
document.getElementById("taskview").innerHTML =
'<div class="tv-bar">' +
'<button class="btn ghost sm" data-tv="close">← Назад</button>' +
'<div class="grow sub">' + esc(t.project_name || "без проекта") + "</div>" +
"</div>" +
'<div class="tv-body">' +
'<label>Название</label>' +
'<input id="tv-title" value="' + esc(t.title) + '" />' +
'<label>Описание</label>' +
'<textarea id="tv-desc" rows="6" oninput="autoGrow(this)" placeholder="Что именно нужно сделать, к чему привязано, что считать результатом">' +
esc(t.description || "") + "</textarea>" +
'<div class="acts" style="margin-top:2px">' +
'<button class="btn ghost sm" data-tv="reword" title="ИИ перепишет название и описание из текста выше. Проект, исполнителя, стрим и срок не тронет. Ничего не сохранится, пока не нажмёте Сохранить.">Сформулировать заново</button>' +
'<button class="btn ghost sm" data-mic="tv-desc"><i class="mic-dot"></i>Надиктовать в описание</button>' +
"</div>" +
'<div class="sub" style="margin-top:2px">Сформулировать заново - ИИ переписывает только название и описание; надиктовка добавляет сказанное в описание.</div>' +
(state.streams.length
? '<label>Стрим</label>' +
'<select id="tv-stream" onchange="cardStreamChanged()">' + cardStreamOptions(t) + "</select>" +
'<div class="sub" style="margin-top:2px">Стрим меняется выбором проекта: выберите стрим - список проектов сузится.</div>'
: "") +
'<label>Проект</label>' +
'<select id="tv-project" onchange="markCardDirty()">' + projectOptions(t) + "</select>" +
'<label>Исполнитель</label>' +
'<select id="tv-assignee" onchange="assigneeChanged()">' + assigneeOptions(c) + "</select>" +
'<div class="filters-col" style="margin-top:2px">' +
'<div><label>Статус</label>' +
'<select id="tv-status" onchange="markCardDirty()">' + COLUMNS.map(function (col) {
return '<option value="' + col.key + '"' + (col.key === t.status ? " selected" : "") + ">" +
col.label + "</option>"; }).join("") + "</select></div>" +
'<div><label>Срок <span class="sub" style="font-weight:400">- когда спросим статус</span></label>' +
'<input id="tv-due" type="date" style="max-width:180px" value="' + esc(t.due || "") + '" onchange="markCardDirty()" /></div>' +
"</div>" +
'<div class="acts" id="tv-newperson" style="display:none">' +
'<input id="tv-newperson-name" class="grow" list="people-names" placeholder="Имя, можно с @ником" />' +
peopleDatalistHtml() +
'<button class="btn sm" onclick="createAndAssignPerson()">Добавить и назначить</button>' +
"</div>" +
'<div id="tv-dirty" class="sub" style="display:none;color:#b26a00">Есть несохранённые изменения - нажмите Сохранить.</div>' +
'<div class="acts"><button class="btn" data-tv="save">Сохранить</button>' +
'<button class="btn ghost" data-tv="delete" style="color:#e53935">Удалить насовсем</button></div>' +
'<div class="meta" style="margin-top:10px">' + meta.join("") + "</div>" +
sourceLineHtml(t) +
(t.source_quote ? '<div class="quote">«' + esc(t.source_quote) + "»</div>" : "") +
'<div class="row" style="margin-top:14px"><h3 class="grow" style="margin:0">Файлы</h3></div>' +
attachmentsHtml(c) +
'<div class="acts">' +
'<input type="file" id="tv-file" class="grow" />' +
'<button class="btn sm" onclick="uploadTaskFile()">Приложить</button>' +
"</div>" +
'<div class="row" style="margin-top:14px;margin-bottom:8px"><h3 class="grow" style="margin:0">Комментарии</h3>' +
'<button class="lnk" data-tv="order">' +
(state.commentOrder === "old" ? "сначала старые" : "сначала новые") + " ⇅</button></div>" +
'<textarea id="tv-comment" rows="2" placeholder="Написать комментарий - ссылки можно прямо в текст…"></textarea>' +
'<div class="acts">' +
'<button class="btn sm" data-tv="comment">Отправить</button>' +
'<button class="btn sm ghost" data-mic="tv-comment"><i class="mic-dot"></i>Надиктовать</button>' +
"</div>" +
'<div style="margin-top:10px">' + commentsHtml(c.comments || []) + "</div>" +
'<details style="margin-top:14px"><summary class="sub">История изменений (техническое)</summary>' +
'<div style="margin-top:8px">' +
((c.history || []).length
? c.history.map(function (h) {
return '<div class="hist">' + esc(shortDate(h.created_at)) + " · " +
esc(sourceLabel(h.source)) + (h.new_status ? " · " + esc(h.new_status) : "") +
(h.note ? " · " + esc(h.note) : "") + "</div>";
}).join("")
: '<div class="empty">Изменений пока не было.</div>') +
"</div></details>" +
"</div>";
}
// Один слушатель на всю карточку — данные лежат в data-атрибутах и никогда
// не попадают в исполняемый код.
document.getElementById("taskview").addEventListener("click", function (ev) {
// Ответы на комментарии - те же data-атрибуты, тот же один слушатель.
const rl = ev.target.closest("[data-reply]");
if (rl) { ev.preventDefault(); toggleReply(rl.getAttribute("data-reply"), true); return; }
const rs = ev.target.closest("[data-replysend]");
if (rs) { sendReply(rs.getAttribute("data-replysend")); return; }
const rc = ev.target.closest("[data-replycancel]");
if (rc) { toggleReply(rc.getAttribute("data-replycancel"), false); return; }
const el = ev.target.closest("[data-tv]");
if (!el) return;
const act = el.getAttribute("data-tv");
if (act === "close") closeTaskView();
else if (act === "delete") deleteTaskTwoTap(el);
else if (act === "save") saveTaskEdit();
else if (act === "reword") rewordTask(el);
else if (act === "comment") addComment();
else if (act === "order") {
state.commentOrder = state.commentOrder === "old" ? "new" : "old";
renderTaskView();
}
});
// Удаление насовсем - в два нажатия: первое меняет кнопку на подтверждение,
// второе удаляет. Диалоги-confirm в вебвью Telegram ведут себя ненадёжно,
// а случайное удаление необратимо - двухтапная защита честнее.
// Три ступени (решение владельца: "удаляют редко, пусть будет тройное"):
// 1) кнопка -> 2) "Точно удалить?" -> 3) окно по центру с Ок/Отмена.
async function deleteTaskTwoTap(btn) {
if (btn.getAttribute("data-armed") !== "1") {
btn.setAttribute("data-armed", "1");
btn.textContent = "Точно удалить насовсем?";
setTimeout(function () {
if (btn && btn.getAttribute) { btn.setAttribute("data-armed", "0"); btn.textContent = "Удалить насовсем"; }
}, 5000);
return;
}
var c = state.card;
if (!c || state.demo) { closeTaskView(); return; }
showConfirm(
"Удаляем задачу «" + (c.task.title || "") + "» насовсем - вместе с комментариями и историей. Вернуть будет нельзя.",
async function () {
try {
await api("/api/tasks/" + encodeURIComponent(c.task.id), { method: "DELETE" });
closeTaskView();
banner("Задача удалена насовсем", "ok");
await load();
} catch (e) { banner("Не удалось удалить: " + e.message, "err"); }
},
);
}
// Своё окно подтверждения по центру: системные confirm в вебвью ненадёжны.
function showConfirm(text, onOk, okLabel, onCancel) {
var wrap = document.createElement("div");
wrap.style.cssText = "position:fixed;inset:0;background:rgba(15,20,25,.45);display:flex;align-items:center;justify-content:center;z-index:1000;padding:20px";
wrap.innerHTML = '<div style="background:#fff;border-radius:14px;max-width:360px;padding:18px 16px;box-shadow:0 8px 40px rgba(0,0,0,.25)">' +
'<div style="margin-bottom:14px">' + esc(text) + "</div>" +
'<div class="acts">' +
'<button class="btn" id="cf-ok" style="background:#e53935">' + esc(okLabel || "Удалить") + "</button>" +
'<button class="btn ghost" id="cf-no">Отмена</button>' +
"</div></div>";
document.body.appendChild(wrap);
wrap.querySelector("#cf-no").onclick = function () { wrap.remove(); if (onCancel) onCancel(); };
wrap.onclick = function (ev) { if (ev.target === wrap) { wrap.remove(); if (onCancel) onCancel(); } };
wrap.querySelector("#cf-ok").onclick = function () { wrap.remove(); onOk(); };
}
async function saveTaskEdit() {
const c = state.card;
if (!c) return;
const title = document.getElementById("tv-title").value.trim();
const description = document.getElementById("tv-desc").value;
if (title === "") { banner("Название не может быть пустым", "err"); return; }
if (state.demo) {
c.task.title = title; c.task.description = description;
const t = state.tasks.filter(function (x) { return x.id === c.task.id; })[0];
if (t) { t.title = title; t.description = description; }
renderTaskView(); render(); banner("Сохранено", "ok"); return;
}
// Кнопка применяет ВСЁ разом: текст, дату чека, статус и, если менялись,
// проект и исполнителя. Отдельных мгновенных сохранений у списков нет.
var projSel = document.getElementById("tv-project");
var asgSel = document.getElementById("tv-assignee");
var dueInp = document.getElementById("tv-due");
var stSel = document.getElementById("tv-status");
var assign = {};
if (projSel && projSel.value !== (c.task.project_id || "")) assign.projectId = projSel.value || null;
if (asgSel && asgSel.value !== "new" && asgSel.value !== assigneeValue(c.task)) {
var v = asgSel.value;
if (v.indexOf("u:") === 0) assign.assigneeUserId = v.slice(2);
else if (v.indexOf("p:") === 0) assign.assigneePersonId = v.slice(2);
else assign.assigneeUserId = null;
}
var edit = { title: title, description: description };
if (dueInp && dueInp.value !== (c.task.due || "")) edit.due = dueInp.value || null;
var newStatus = stSel && stSel.value !== c.task.status ? stSel.value : null;
try {
await api("/api/tasks/" + encodeURIComponent(c.task.id) + "/edit", {
method: "PATCH", body: JSON.stringify(edit),
});
if (Object.keys(assign).length) {
await api("/api/tasks/" + encodeURIComponent(c.task.id) + "/assign", {
method: "PATCH", body: JSON.stringify(assign),
});
}
if (newStatus) {
await api("/api/tasks/" + encodeURIComponent(c.task.id), {
method: "PATCH", body: JSON.stringify({ status: newStatus }),
});
}
banner("Сохранено", "ok");
await load();
// Сохранили - работа с карточкой закончена, возвращаемся к списку задач.
closeTaskView();
} catch (e) { banner("Не удалось сохранить: " + e.message, "err"); }
}
// "Переформулируй": ИИ заново раскладывает текст задачи в название и
// описание. Только ПОДСТАВЛЯЕТ в поля - сохраняет по-прежнему кнопка,
// не понравилось - жмите ещё раз или закройте карточку без сохранения.
async function rewordTask(btn) {
var c = state.card;
if (!c) return;
if (state.demo) { banner("В демо-режиме недоступно", "warn"); return; }
var src = document.getElementById("tv-title").value + "\n\n" +
document.getElementById("tv-desc").value;
var old = btn ? btn.textContent : "";
if (btn) { btn.disabled = true; btn.textContent = "Формулирую…"; }
try {
var r = await api("/api/tasks/dictate", {
method: "POST",
body: JSON.stringify({ text: src, projectId: c.task.project_id || null, mode: "smart", preview: true }),
});
var f = (r && r.fields) || {};
if (!f.title) throw new Error("модель не ответила, попробуйте ещё раз");
document.getElementById("tv-title").value = f.title;
if (f.description) document.getElementById("tv-desc").value = f.description;
markCardDirty();
banner("Подставил новую формулировку - проверьте и нажмите Сохранить", "ok");
} catch (e) { banner("Не получилось: " + e.message, "err"); }
finally { if (btn) { btn.disabled = false; btn.textContent = old; } }
}
async function addComment() {
const c = state.card;
if (!c) return;
const box = document.getElementById("tv-comment");
const text = box.value.trim();
if (text === "") {
banner("Напишите или надиктуйте текст комментария - поле пустое.", "warn");
return;
}
if (state.demo) {
c.comments = c.comments || [];
c.comments.unshift({ id: "c" + c.comments.length, body: text, author_name: "вы",
source: "manual", created_at: new Date().toISOString(), parent_comment_id: null });
box.value = ""; renderTaskView(); return;
}
try {
await api("/api/tasks/" + encodeURIComponent(c.task.id) + "/comments", {
method: "POST", body: JSON.stringify({ body: text }),
});
box.value = "";
banner("Комментарий отправлен", "ok");
await openTask(c.task.id);
await load();
} catch (e) { banner("Не удалось отправить: " + e.message, "err"); }
}
// --------------------------- Расписания ---------------------------
// Смысл блока: показать, что бот делает сам и когда. Системный планировщик
// невидим и ломается молча - здесь видно последний прогон и его исход.
const JOB_NAMES = {
digest_evening: "Вечерняя сводка",
digest_morning: "Утренняя догоняющая сводка",
assignee_notify: "Уведомления исполнителям",
stale_nudge: "Напоминания о застое",
exchange_cleanup: "Уборка журнала обмена",
leader_rollup: "Личная сводка лидеру",
voice_transcribe: "Расшифровка голосовых",
map_update: "Карта месяца",
ops_report: "Отчёт о ресурсах",
dm_review: "Разбор личной переписки",
weekly_move: "Движение за неделю",
meeting_process: "Разбор записей встреч",
team_review: "Разбор команды (пятница)",
};
// Что это за инструмент и куда пишет - по просьбе владельца 11.08.
const JOB_DESC = {
digest_evening: "итоги дня: в группу проекта",
digest_morning: "догоняющая: молчит, если за ночь ничего не случилось",
assignee_notify: "личное сообщение исполнителю о новой задаче",
stale_nudge: "напоминание про задачи без движения: лидеру",
exchange_cleanup: "уборка технического журнала, людям не пишет",
leader_rollup: "картина по всем проектам: лидеру в личку",
voice_transcribe: "переводит голосовые в текст для разборов",
map_update: "раз в месяц собирает карту главных событий проекта",
ops_report: "раз в неделю: минуты распознавания, очереди, база",
dm_review: "ищет явные задачи в личной переписке каждые 3 часа",
weekly_move: "понедельник 09:00: закрыто и новое за неделю",
meeting_process: "ведёт записи встреч по конвейеру до задач",
team_review: "пятница 17:00: цифры по каждому человеку, лидеру",
};
const RUN_STATE = {
ok: ["сработало", "hi"],
error: ["ошибка", "lo"],
skipped: ["пропущено", "warn"],
running: ["идёт", ""],
};
function renderJobs() {
const box = document.getElementById("jobs-list");
if (!box) return;
if (!state.jobs.length) {
box.innerHTML = '<div class="empty">Расписаний пока нет. Их заводит администратор ' +
'на сервере командой jobs setup.</div>';
return;
}
// Сводка сверху: сколько отработало без ошибок. Подробности - раскрытием.
var okCount = state.jobs.filter(function (j) { return j.last_status !== "error"; }).length;
var bad = state.jobs.filter(function (j) { return j.last_status === "error"; });
var head = bad.length === 0
? '<div class="card"><b>Всё отработало: ' + okCount + " из " + state.jobs.length +
'</b><div class="sub">Ошибок нет. Подробности по каждому расписанию - ниже.</div></div>'
: '<div class="card"><b>Есть ошибки: ' + bad.length + '</b><div class="sub">' +
bad.map(function (j) { return esc(JOB_NAMES[j.kind] || j.kind); }).join(", ") +
" - подробности ниже.</div></div>";
box.innerHTML = head + state.jobs.map(function (j) {
// Имя проекта у сводок: без него две "вечерние сводки" разных проектов
// выглядели необъяснимыми дублями.
const name = (JOB_NAMES[j.kind] || j.kind) + (j.project_name ? " - " + j.project_name : "");
const st = RUN_STATE[j.last_status] || ["ещё не запускалась", ""];
const meta = [
'<span class="tag">' + esc(j.schedule_text || "") + "</span>",
'<span class="tag ' + st[1] + '">' + esc(st[0]) + "</span>",
];
if (!j.enabled) meta.push('<span class="tag warn">выключено</span>');
if (j.fail_streak > 0) meta.push('<span class="tag lo">неудач подряд: ' + j.fail_streak + "</span>");
return '<div class="task">' +
'<div style="font-weight:600;color:var(--fg)">' + esc(name) + "</div>" +
(JOB_DESC[j.kind] ? '<div class="sub">' + esc(JOB_DESC[j.kind]) + "</div>" : "") +
'<div class="meta" style="margin-top:4px">' + meta.join("") + "</div>" +
(j.last_detail ? '<div class="hint">' + esc(j.last_detail) + "</div>" : "") +
'<div class="hint">следующий запуск: ' + esc(shortDate(j.next_run_at)) + "</div>" +
'<div class="acts">' +
'<button class="btn ghost sm" data-job="run" data-id="' + esc(j.id) + '">Запустить сейчас</button>' +
'<button class="btn ghost sm" data-job="toggle" data-id="' + esc(j.id) +
'" data-on="' + (j.enabled ? "1" : "0") + '">' +
(j.enabled ? "Выключить" : "Включить") + "</button>" +
"</div></div>";
}).join("");
}
document.getElementById("sec-profile").addEventListener("click", function (ev) {
const el = ev.target.closest("[data-job]");
if (!el) return;
const id = el.getAttribute("data-id");
if (el.getAttribute("data-job") === "run") runJob(id);
else toggleJob(id, el.getAttribute("data-on") !== "1");
});
async function runJob(id) {
if (state.demo) { banner("В демо-режиме расписания не запускаются", "warn"); return; }
try {
await api("/api/jobs/" + encodeURIComponent(id) + "/run", { method: "POST" });
banner("Запустится в течение минуты", "ok");
} catch (e) { banner("Не удалось запустить: " + e.message, "err"); }
}
async function toggleJob(id, enabled) {
if (state.demo) { banner("В демо-режиме расписания не меняются", "warn"); return; }
try {
await api("/api/jobs/" + encodeURIComponent(id), {
method: "PATCH", body: JSON.stringify({ enabled: enabled }),
});
await load();
} catch (e) { banner("Не удалось изменить: " + e.message, "err"); }
}
// --------------------------- Люди проекта ---------------------------
// Telegram не отдаёт ботам список участников группы, поэтому люди собираются
// по мере того, как пишут. Здесь видно, кого система уже знает.
function renderPeople() {
const box = document.getElementById("people-list");
if (!box) return;
if (!state.people.length) {
box.innerHTML = '<div class="empty">Пока никого. Люди появятся, как только начнут ' +
"писать в группе проекта.</div>";
return;
}
box.innerHTML = state.people.map(function (p) {
const tags = [];
tags.push('<span class="tag ' + (p.in_tracker ? "hi" : "") + '">' +
(p.in_tracker ? "в трекере" : "только в чате") + "</span>");
if (p.discovered_via === "join") tags.push('<span class="tag">добавлен в группу</span>');
return '<div class="task"><div class="title" style="cursor:default">' +
esc(p.display_name || "без имени") + "</div>" +
'<div class="meta">' + tags.join("") + "</div>" +
'<div class="hint">последний раз писал: ' + esc(shortDate(p.last_seen_at)) + "</div></div>";
}).join("");
}
document.getElementById("sec-projects").addEventListener("click", function (ev) {
const el = ev.target.closest("[data-people]");
if (!el) return;
loadPeople();
});
// Транскрипт встречи. Слушатель ПЕРЕЕХАЛ вместе с блоком встреч: он висел на
// вкладке Проектов, а встречи уехали в свою - кнопка "Транскрипт" делала вид,
// что нажимается, и молчала (живой урок 12.08). Урок: переносишь разметку -
// переноси и делегированные слушатели.
document.getElementById("sec-meetings").addEventListener("click", function (ev) {
const tr = ev.target.closest("[data-mtr]");
if (tr) toggleTranscript(tr.getAttribute("data-mtr"));
});
// Перенос проекта между стримами - select на карточке проекта. Событие change
// не всплывает как click, поэтому слушатель отдельный, тем же делегированием.
document.getElementById("sec-projects").addEventListener("change", function (ev) {
const sel = ev.target.closest("[data-pstream]");
if (!sel) return;
changeProjectStream(sel.getAttribute("data-pstream"), sel.value || null);
});
async function loadPeople() {
const sel = document.getElementById("pp-project");
const projectId = sel && sel.value;
if (!projectId) { banner("Сначала выберите проект", "warn"); return; }
if (state.demo) {
state.people = [
{ id: "p1", display_name: "Коля", in_tracker: true, discovered_via: "message",
last_seen_at: new Date().toISOString() },
{ id: "p2", display_name: "Петя", in_tracker: false, discovered_via: "join",
last_seen_at: new Date().toISOString() },
];
renderPeople(); return;
}
try {
const r = await api("/api/projects/" + encodeURIComponent(projectId) + "/people");
state.people = r.people || [];
renderPeople();
} catch (e) { banner("Не удалось загрузить людей: " + e.message, "err"); }
}
// ------------------------------ Карта ------------------------------
// "2026-08" -> "Август 2026" для заголовков месяцев.
function monthLabel(m) {
const NAMES = ["Январь","Февраль","Март","Апрель","Май","Июнь",
"Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
const p = /^(\d{4})-(\d{2})$/.exec(m || "");
if (!p) return m || "";
return (NAMES[Number(p[2]) - 1] || p[2]) + " " + p[1];
}
// Карта месяца - визуальный таймлайн (решение владельца 12.08, по правилам
// diagram-design): вертикальная волосяная ось, даты моноширинным слева,
// события справа; расстояния пропорциональны настоящим датам (честная
// шкала); веха месяца - одна, акцентом; проблема - красным; риск - жёлтым
// ромбом. Клик по событию открывает задачу.
function mapDotColor(kind) {
if (kind === "milestone") return "var(--accent)";
if (kind === "problem") return "#c62828";
if (kind === "risk") return "var(--warn)";
return "var(--fg)";
}
function mapTimelineSvg(items) {
// Даты: день создания задачи; без даты - равномерно между соседями.
var evs = items.slice().sort(function (a, b) {
return String(a.task_created || "").localeCompare(String(b.task_created || ""));
});
// Веха: помеченная моделью, иначе первая по рангу (старые карты без вида).
if (!evs.some(function (e) { return e.kind === "milestone"; })) {
var top = items.slice().sort(function (a, b) { return a.rank - b.rank; })[0];
evs.forEach(function (e) { if (e === top) e.kind = "milestone"; });
}
// Честная шкала: позиция по дню месяца, потом раздвигаем до минимума.
var H_PER_DAY = 11, MIN_GAP = 64, PAD = 24;
var ys = [];
evs.forEach(function (e, i) {
var day = e.task_created ? Number(String(e.task_created).slice(8, 10)) : (i + 1) * 5;
var y = PAD + (day - 1) * H_PER_DAY;
if (i > 0 && y < ys[i - 1] + MIN_GAP) y = ys[i - 1] + MIN_GAP;
ys.push(y);
});
var height = (ys[ys.length - 1] || PAD) + 70;
var parts = ['<line x1="60" y1="10" x2="60" y2="' + (height - 14) +
'" stroke="#c6cdd6" stroke-width="1"/>'];
evs.forEach(function (e, i) {
var y = ys[i];
var col = mapDotColor(e.kind);
var date = e.task_created ? fmtDue(e.task_created) : "";
parts.push('<g style="cursor:pointer" onclick="openTask(\'' + esc(e.task_id) + '\')">');
if (date) {
parts.push('<text x="50" y="' + (y + 4) + '" text-anchor="end" font-family="ui-monospace,Menlo,monospace" ' +
'font-size="10" fill="#7a8399">' + esc(date) + "</text>");
}
if (e.kind === "risk") {
parts.push('<rect x="55" y="' + (y - 5) + '" width="10" height="10" fill="' + col +
'" transform="rotate(45 60 ' + y + ')"/>');
} else {
parts.push('<circle cx="60" cy="' + y + '" r="' + (e.kind === "milestone" ? 6 : 4) +
'" fill="' + col + '"/>');
}
parts.push('<line x1="' + (e.kind === "milestone" ? 66 : 64) + '" y1="' + y +
'" x2="84" y2="' + y + '" stroke="#c6cdd6" stroke-width="1"/>');
var titleColor = "var(--fg)";
parts.push('<text x="88" y="' + (y - 2) + '" font-size="12" font-weight="600" fill="' + titleColor + '">' +
esc(String(e.task_title).slice(0, 44)) + "</text>");
var subColor = e.kind === "problem" ? "#c62828" : "var(--muted)";
parts.push('<text x="88" y="' + (y + 13) + '" font-size="10.5" fill="' + subColor + '">' +
esc(String(e.why).slice(0, 52)) + "</text>");
if (e.kind === "milestone" && e.quote) {
parts.push('<text x="88" y="' + (y + 28) + '" font-size="10.5" font-style="italic" fill="var(--muted)">«' +
esc(String(e.quote).slice(0, 46)) + "»</text>");
}
parts.push("</g>");
});
return '<svg viewBox="0 0 400 ' + height + '" width="100%" style="max-width:430px">' +
parts.join("") + "</svg>";
}
function renderMap() {
const box = document.getElementById("map-list");
if (!state.highlights.length) {
box.innerHTML = '<div class="empty">Карта пока пуста. Она собирается сама в начале ' +
'месяца из самых обсуждаемых и важных задач - поживите с проектом месяц.</div>';
return;
}
// Группировка: месяц (свежие первыми) -> проект -> таймлайн.
const months = [];
const byMonth = {};
state.highlights.forEach(function (h) {
if (!byMonth[h.month]) { byMonth[h.month] = []; months.push(h.month); }
byMonth[h.month].push(h);
});
box.innerHTML = months.map(function (m) {
const items = byMonth[m];
const projects = [];
const byProject = {};
items.forEach(function (h) {
if (!byProject[h.project_name]) { byProject[h.project_name] = []; projects.push(h.project_name); }
byProject[h.project_name].push(h);
});
const inner = projects.map(function (pn) {
return '<div class="card">' +
'<div class="sub" style="letter-spacing:.14em;text-transform:uppercase;font-size:10px">' +
esc(pn) + "</div>" +
mapTimelineSvg(byProject[pn]) +
"</div>";
}).join("");
return '<h3 style="margin:16px 0 8px">' + esc(monthLabel(m)) + '</h3>' + inner;
}).join("");
}
// ------------------------------ Проекты ------------------------------
function renderProjects() {
// Проекты группируются по стримам (модель: Лидер -> Стримы -> Проекты).
// На карточке проекта - выпадающий список стрима: перенос в один жест.
function streamSelect(p) {
var opts = '<option value=""' + (p.stream_id ? "" : " selected") + ">без стрима</option>";
opts += state.streams.map(function (s) {
return '<option value="' + esc(s.id) + '"' + (p.stream_id === s.id ? " selected" : "") + ">" +
esc(s.name) + "</option>";
}).join("");
return '<label style="margin-top:8px">Стрим</label><select data-pstream="' + esc(p.id) + '">' + opts + "</select>";
}
function projectCard(p) {
var badge = p.kind === "one_on_one" ? ' <span class="tag">личный (1-1)</span>' : "";
return '<div class="card"><div class="sub" style="font-size:11px;letter-spacing:.12em;text-transform:uppercase">Проект</div>' +
'<div><b>' + esc(p.name) + "</b>" + badge + "</div>" +
'<div class="sub">' + (p.legend ? esc(p.legend) : "легенда не задана") + "</div>" +
(p.kind === "one_on_one" ? "" : streamSelect(p)) + "</div>";
}
// Фильтр по стриму (просьба владельца: "в проектах нужны фильтры по стримам").
var pf = document.getElementById("pf-stream");
if (pf && pf.options.length !== state.streams.length + 1) {
var prev = pf.value;
pf.innerHTML = '<option value="all">все стримы</option>' + state.streams.map(function (st) {
return '<option value="' + esc(st.id) + '">' + esc(st.name) + "</option>";
}).join("");
pf.value = [...pf.options].some(function (o) { return o.value === prev; }) ? prev : "all";
}
var chosenStream = pf ? pf.value : "all";
var regular = state.projects.filter(function (p) {
if (p.kind === "one_on_one") return false;
if (chosenStream !== "all" && p.stream_id !== chosenStream) return false;
return true;
});
var others = state.projects.filter(function (p) { return p.kind === "one_on_one"; });
var html = "";
state.streams.forEach(function (s) {
// Выбран стрим - заголовки остальных не показываем вовсе ("я же выбрал
// азартные игры, почему вижу новый дом" - разбор 12.08).
if (chosenStream !== "all" && s.id !== chosenStream) return;
var inStream = regular.filter(function (p) { return p.stream_id === s.id; });
html += '<div class="sub" style="margin:10px 0 6px">Стрим · <b>' + esc(s.name) + "</b>" +
(inStream.length ? "" : " · проектов нет") + "</div>";
html += inStream.map(projectCard).join("");
});
var orphans = regular.filter(function (p) { return !p.stream_id; });
if (orphans.length) {
html += state.streams.length
? '<div class="sub" style="margin:10px 0 6px"><b>Без стрима</b></div>'
: "";
html += orphans.map(projectCard).join("");
}
html += others.map(projectCard).join("");
document.getElementById("projects-list").innerHTML =
html || '<div class="empty">Проектов пока нет - создайте первый.</div>';
const ppOptions = state.projects.map(function (p) {
return '<option value="' + esc(p.id) + '">' + esc(p.name) + "</option>";
}).join("");
const pp = document.getElementById("pp-project");
if (pp) pp.innerHTML = ppOptions || '<option value="">нет проектов</option>';
// Проект в форме встречи: только настоящие проекты; стрим сужает список.
const ms = document.getElementById("mt-stream");
if (ms && ms.options.length !== state.streams.length + 1) {
ms.innerHTML = '<option value="all">все стримы</option>' + state.streams.map(function (st) {
return '<option value="' + esc(st.id) + '">' + esc(st.name) + "</option>";
}).join("");
}
fillMeetingProjects();
// Проект живой комнаты: тот же список.
const rp = document.getElementById("rm-project");
if (rp) {
rp.innerHTML = state.projects.filter(function (p) { return p.kind !== "one_on_one"; })
.map(function (p) {
return '<option value="' + esc(p.id) + '">' + esc(p.name) + "</option>";
}).join("") || '<option value="">нет проектов</option>';
}
// Стрим в форме нового проекта: по умолчанию - без стрима.
const ps = document.getElementById("p-stream");
if (ps) {
ps.innerHTML = '<option value="">без стрима</option>' + state.streams.map(function (s) {
return '<option value="' + esc(s.id) + '">' + esc(s.name) + "</option>";
}).join("");
}
document.getElementById("c-project").innerHTML = state.projects.map(function (p) {
return '<option value="' + esc(p.id) + '">' + esc(p.name) + "</option>";
}).join("");
// Выпадающий список групп: ТОЛЬКО группы. Каналы личек (tg_dm) здесь
// сбивали с толку - "почему тут мои контакты?!" (разбор 11.08).
var groupsOnly = state.channels.filter(function (c) { return c.type !== "tg_dm"; });
var chatSel = document.getElementById("c-chat");
chatSel.innerHTML = groupsOnly.length
? groupsOnly.map(function (c) {
var label = c.title ? c.title : "чат " + c.tg_chat_id;
if (c.project_name) label += " → " + c.project_name;
if (isDeadChannel(c)) label += " (номер без минуса, не заработает)";
return '<option value="' + esc(c.id) + '">' + esc(label) + "</option>";
}).join("")
: '<option value="">бота ещё не добавляли в группы</option>';
document.getElementById("channels-list").innerHTML = groupsOnly.length
? groupsOnly.map(function (c, ci) {
var where = c.project_name ? esc(c.project_name) : "проект не выбран";
// Название группы приходит из Telegram; если его нет (бота добавили,
// но в группе ещё никто не писал) - честно так и говорим.
var name = c.title ? esc(c.title) : "группа без названия";
var dead = isDeadChannel(c);
// Ссылка на сам чат (разбор 12.08: "дай ссылку на группу, а не номер").
// Открывается у участников группы; публичной ссылки у закрытых групп нет.
var link = tgGroupLink(c);
return '<div class="card"><div><b>' + name + "</b>" +
(link ? ' <a href="' + esc(link) + '" target="_blank" class="sub">открыть чат</a>' : "") +
"</div>" +
(c.tg_title && c.title !== c.tg_title
? '<div class="sub">в Telegram: «' + esc(c.tg_title) + "»</div>" : "") +
'<div class="meta" style="margin-top:6px">' +
'<span class="tag ' + (c.project_name ? "hi" : "warn") + '">' + where + "</span>" +
(c.bot_present ? "" :
'<span class="tag lo" title="Бота исключили из этой группы Telegram. Добавьте его обратно и напишите в группу любое сообщение - связь восстановится.">бота нет в группе</span>') +
(dead ? '<span class="tag warn">номер без минуса - чат не существует</span>' : "") +
"</div>" +
(dead
? '<div class="sub" style="margin-top:6px">Идентификаторы групп в Telegram всегда отрицательные. ' +
'Такой канал не заработает: бот не может ни писать в него, ни получать сообщения.</div>' +
'<div class="acts"><button class="btn sm ghost" data-chdel="' + esc(c.id) + '">Убрать</button></div>'
: '<div class="acts"><button class="btn ghost sm" onclick="toggleChannelRename(' + ci + ')">Переименовать</button></div>' +
'<div id="chr-' + ci + '" style="display:none">' +
'<label>Имя для todoiter (в Telegram группа не переименуется)</label>' +
'<input id="chr-name-' + ci + '" value="' + esc(c.title || "") + '" placeholder="Пусто - вернуть имя из Telegram" />' +
'<div class="acts" style="margin-top:6px">' +
'<button class="btn sm" onclick="saveChannelRename(' + ci + ')">Сохранить</button>' +
"</div>" +
"</div>") +
"</div>";
}).join("")
: '<div class="empty">Групп пока нет. Добавьте бота в рабочую группу - она появится здесь через несколько секунд. Важно: добавить должны именно вы или другой участник вашего пространства.</div>';
}
/** Ссылка на чат Telegram: по юзернейму или через t.me/c/ для супергрупп. */
function tgGroupLink(c) {
if (c.username) return "https://t.me/" + c.username;
var n = String(c.tg_chat_id || "");
if (n.indexOf("-100") === 0 && n.length > 4) return "https://t.me/c/" + n.slice(4);
return null;
}
function toggleChannelRename(ci) {
var el = document.getElementById("chr-" + ci);
if (el) el.style.display = el.style.display === "none" ? "" : "none";
}
async function saveChannelRename(ci) {
var groupsOnly = state.channels.filter(function (c) { return c.type !== "tg_dm"; });
var c = groupsOnly[ci];
var inp = document.getElementById("chr-name-" + ci);
if (!c || !inp) return;
if (state.demo) { banner("В демо-режиме переименование недоступно.", "warn"); return; }
try {
await api("/api/channels/" + encodeURIComponent(c.id), {
method: "PATCH",
body: JSON.stringify({ title: inp.value }),
});
banner("Группа переименована для todoiter", "ok");
await load();
} catch (e) { banner("Не переименовалась: " + e.message, "err"); }
}
/**
* Мёртвый ли канал.
*
* У Telegram идентификаторы групп ВСЕГДА отрицательные, положительные - у
* людей. Потерянный при ручном вводе минус создаёт канал, в который бот
* физически не может ни писать, ни получать из него сообщения. В списке он
* висит рядом с настоящим и отличается одним символом - поэтому помечаем.
*/
function isDeadChannel(c) {
var n = Number(c.tg_chat_id);
return c.type === "tg_group" && isFinite(n) && n > 0;
}
async function removeChannel(id) {
if (state.demo) { banner("В демо-режиме удаление недоступно.", "warn"); return; }
try {
await api("/api/channels/" + encodeURIComponent(id), { method: "DELETE" });
banner("Канал убран", "ok");
await load();
} catch (e) { banner("Не удалось убрать: " + e.message, "err"); }
}
async function createProject() {
const name = document.getElementById("p-name").value.trim();
const legend = document.getElementById("p-legend").value.trim();
const streamId = document.getElementById("p-stream").value || null;
if (!name) return;
if (state.demo) { banner("В демо-режиме создание недоступно.", "warn"); return; }
try {
await api("/api/projects", { method: "POST", body: JSON.stringify({ name: name, legend: legend, streamId: streamId }) });
document.getElementById("p-name").value = "";
document.getElementById("p-legend").value = "";
banner("Проект создан", "ok");
await load();
} catch (e) { banner("Не удалось создать проект: " + e.message, "err"); }
}
// ------------------------- Живая встреча (комнаты) -------------------------
//
// Этап 1 без своего видеосервера: телефон пишет ТОЛЬКО голос владельца
// (MediaRecorder), дорожка уезжает по завершении. Пока идёт запись, комната
// опрашивается раз в 10 секунд: закрыли встречу - запись сама останавливается
// и отправляется. Часы телефона не берутся на веру: вместе с началом записи
// уходит "сейчас" телефона, сервер считает перекос.
var roomRec = { rec: null, stream: null, chunks: [], startMs: 0, roomId: null, timer: null, poll: null, lock: null };
function activeRoom() {
return (state.rooms || []).find(function (r) { return r.status === "open" || r.status === "closed"; }) || null;
}
function renderRooms() {
var none = document.getElementById("room-none");
var live = document.getElementById("room-live");
if (!none || !live) return;
var room = activeRoom();
none.style.display = room ? "none" : "";
live.style.display = room ? "" : "none";
if (room) {
document.getElementById("rm-live-title").textContent = room.title + " · " + (room.project || "");
document.getElementById("rm-live-status").textContent = room.statusLabel || room.status;
var hint = roomRec.rec
? "Идёт запись вашего голоса - не закрывайте приложение и не блокируйте экран."
: (room.status === "open"
? "«Включить микрофон» - телефон начнёт писать ваш голос. То же делает каждый участник у себя."
: "Комната закрыта: 3 минуты ждём отстающие загрузки, потом дорожки расшифровываются по одной. Обычно встреча появляется в списке через 3-7 минут; застрявшая дорожка уходит на повтор через полчаса.");
var tl = (room.trackList || []).map(function (t) {
var st = t.status === "transcribed" || t.status === "merged" ? "расшифрована"
: t.status === "transcribing" ? "расшифровывается"
: t.status === "uploaded" ? "в очереди"
: t.status === "failed" ? "не расшифровалась" : t.status;
return esc(t.name) + ": " + st + (t.attempts > 1 ? " (попытка " + t.attempts + ")" : "");
}).join(" · ");
document.getElementById("rm-live-hint").textContent = hint + (tl ? " Дорожки - " + tl + "." : "");
var recBtn = document.getElementById("rm-rec");
recBtn.style.display = room.status === "open" || roomRec.rec ? "" : "none";
document.getElementById("rm-close").style.display = room.status === "open" ? "" : "none";
} else if (roomRec.rec) {
// Комнаты больше нет (разобрана), а запись ещё идёт - останавливаем.
stopRoomRec(true);
}
var box = document.getElementById("rooms-list");
if (box) {
var past = (state.rooms || []).filter(function (r) { return r.status === "done" || r.status === "failed"; });
// done = встреча создана: подсказываем, что смотреть её - в списке встреч ниже.
box.innerHTML = past.map(function (r) {
var cls = r.status === "done" ? "hi" : "warn";
return '<div class="card"><div><b>' + esc(r.title) + '</b>' +
(r.project ? ' <span class="tag">' + esc(r.project) + '</span>' : '') + '</div>' +
'<div class="meta" style="margin-top:6px"><span class="tag ' + cls + '">' + esc(r.statusLabel) + '</span>' +
(r.error ? ' <span class="sub">' + esc(r.error) + '</span>' : '') + '</div>' +
(r.status === "done" ? '<div class="sub" style="margin-top:6px">Транскрипт и задачи - в списке «Встречи» ниже.</div>' : '') +
'</div>';
}).join("");
}
}
// --------------------- Запись чужого звонка (вкладка + микрофон) ---------------------
//
// Решение владельца: чужой Zoom пишем у СЕБЯ - звук вкладки берётся цифро,
// до наушников, микрофон гарнитуры - отдельно. Смешиваем в один поток и
// отправляем обычной встречей: дальше диаризация, "кто говорит", задачи.
var callRec = { rec: null, chunks: [], mic: null, disp: null, ctx: null, timer: null, startMs: 0,
bytes: 0, lock: null };
// Отмена записи звонка - через окно подтверждения: час разговора не должен
// умирать от одного случайного тапа.
function cancelCallRec() {
if (!callRec.rec) return;
showConfirm("Выбросить запись звонка без отправки? Вернуть её будет нельзя.",
function () { finishCallRec(false); }, "Выбросить");
}
async function startCallRec() {
if (callRec.rec) return;
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
banner("Здесь захват вкладок недоступен - возьмите ссылку на рекордер ниже и откройте её в Chrome.", "warn");
return;
}
var projectId = document.getElementById("rm-project").value;
if (!projectId) { banner("Выберите проект звонка.", "warn"); return; }
try {
callRec.mic = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) { banner("Микрофон недоступен: разрешите запись.", "warn"); return; }
try {
callRec.disp = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
} catch (e) {
stopCallTracks();
banner("Захват отменён.", "warn");
return;
}
if (!callRec.disp.getAudioTracks().length) {
stopCallTracks();
banner("Звук вкладки не выбран. Повторите и поставьте галочку «делиться звуком вкладки».", "err");
return;
}
var Ctx = window.AudioContext || window.webkitAudioContext;
callRec.ctx = new Ctx();
var dest = callRec.ctx.createMediaStreamDestination();
callRec.ctx.createMediaStreamSource(callRec.mic).connect(dest);
callRec.ctx.createMediaStreamSource(new MediaStream(callRec.disp.getAudioTracks())).connect(dest);
var mime = window.MediaRecorder && MediaRecorder.isTypeSupported &&
MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "";
callRec.chunks = [];
callRec.bytes = 0;
callRec.rec = mime ? new MediaRecorder(dest.stream, { mimeType: mime }) : new MediaRecorder(dest.stream);
callRec.rec.ondataavailable = function (ev) {
if (ev.data && ev.data.size) { callRec.chunks.push(ev.data); callRec.bytes += ev.data.size; }
};
callRec.rec.start(15000);
callRec.startMs = Date.now();
// Экран не должен гаснуть посреди звонка - иначе вебвью усыпит запись.
acquireLock(callRec);
// Человек нажал "остановить доступ" в браузере - честно завершаем и отправляем.
callRec.disp.getVideoTracks()[0].onended = function () { finishCallRec(true); };
document.getElementById("call-live").style.display = "";
var t = document.getElementById("call-timer");
callRec.timer = setInterval(function () {
var sec = Math.round((Date.now() - callRec.startMs) / 1000);
// Рядом со временем - сколько уже записано: видно, что запись живая.
var mb = callRec.bytes ? " · " + (callRec.bytes / 1048576).toFixed(1) + " МБ" : "";
t.textContent = Math.floor(sec / 60) + ":" + ("0" + (sec % 60)).slice(-2) + mb;
}, 1000);
startRecMeter(dest.stream, document.getElementById("call-finish"));
banner("Запись звонка идёт. Вернитесь во вкладку со встречей.", "ok");
}
function stopCallTracks() {
["mic", "disp"].forEach(function (k) {
if (callRec[k]) { callRec[k].getTracks().forEach(function (tr) { tr.stop(); }); callRec[k] = null; }
});
if (callRec.ctx) { try { callRec.ctx.close(); } catch (e) {} callRec.ctx = null; }
if (callRec.timer) { clearInterval(callRec.timer); callRec.timer = null; }
if (callRec.lock) { try { callRec.lock.release(); } catch (e) {} callRec.lock = null; }
stopRecMeter();
var live = document.getElementById("call-live");
if (live) live.style.display = "none";
}
/** Спасение записи: скачать файлом на компьютер, чтобы загрузить руками. */
function saveBlobLocally(blob, name) {
try {
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 30000);
return true;
} catch (e) { return false; }
}
async function finishCallRec(send) {
if (!callRec.rec) { stopCallTracks(); return; }
var rec = callRec.rec;
callRec.rec = null;
var mimeType = rec.mimeType || "audio/webm";
await new Promise(function (resolve) { rec.onstop = resolve; try { rec.stop(); } catch (e) { resolve(); } });
var blob = new Blob(callRec.chunks, { type: mimeType });
callRec.chunks = [];
var durMin = Math.max(1, Math.round((Date.now() - callRec.startMs) / 60000));
stopCallTracks();
if (!send || !blob.size) { banner("Запись отменена.", "warn"); return; }
var title = (document.getElementById("rm-title").value || "").trim() ||
("Звонок " + new Date().toLocaleDateString("ru-RU").slice(0, 5) + " " +
new Date().toTimeString().slice(0, 5));
var projectId = document.getElementById("rm-project").value;
banner("Отправляю запись звонка (" + durMin + " мин)…");
// Запись существует в одном экземпляре, поэтому сбой сети - не приговор:
// предлагаем повторить отправку, а отказ спасаем файлом на компьютер.
for (;;) {
try {
var res = await fetch(API_BASE + "/api/meetings", {
method: "POST",
headers: Object.assign(authHeaders(), {
"Content-Type": blob.type || "audio/webm",
"X-Meeting-Title": encodeURIComponent(title),
"X-Meeting-Project": projectId,
}),
body: blob,
});
var data = await res.json();
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
document.getElementById("rm-title").value = "";
banner("Звонок отправлен - расшифровка началась, статус в списке встреч.", "ok");
await load();
return;
} catch (e) {
var retry = await new Promise(function (resolve) {
showConfirm("Запись не отправилась: " + e.message + ". Попробовать ещё раз?",
function () { resolve(true); }, "Отправить снова", function () { resolve(false); });
});
if (retry) continue;
var fname = "звонок-" + new Date().toISOString().slice(0, 16).replace("T", "-").replace(":", "") +
(mimeType.indexOf("webm") !== -1 ? ".webm" : ".bin");
var saved = saveBlobLocally(blob, fname);
banner(saved
? "Запись скачана файлом " + fname + " - загрузите её как запись встречи, когда сеть вернётся."
: "Скачать файлом не вышло. Не закрывайте страницу: запись ещё можно спасти повторной отправкой через Завершить.", "err");
return;
}
}
}
async function openCallLink() {
try {
var r = await api("/api/call-link");
var box = document.getElementById("call-link-box");
document.getElementById("call-link-url").value = r.url;
box.style.display = "";
} catch (e) { banner("Не выдалась ссылка: " + e.message, "err"); }
}
function copyCallLink() {
var inp = document.getElementById("call-link-url");
inp.select();
try { navigator.clipboard.writeText(inp.value); banner("Скопировано", "ok"); }
catch (e) { document.execCommand("copy"); banner("Скопировано", "ok"); }
}
async function openRoom() {
var title = document.getElementById("rm-title").value.trim();
var projectId = document.getElementById("rm-project").value;
if (!title) { needField("rm-title", "Название встречи - без него комнату не открыть"); return; }
if (!projectId) { banner("Выберите проект.", "warn"); return; }
if (state.demo) { banner("В демо-режиме комнаты недоступны.", "warn"); return; }
try {
var res = await fetch(API_BASE + "/api/rooms", {
method: "POST",
headers: Object.assign(authHeaders(), { "Content-Type": "application/json" }),
body: JSON.stringify({ projectId: projectId, title: title }),
});
var data = await res.json();
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
document.getElementById("rm-title").value = "";
banner("Комната открыта. Каждый участник у себя: Встречи - «Включить микрофон». Кнопка «Позвать участников» скопирует приглашение.", "ok");
await load();
} catch (e) { banner("Не открылась: " + e.message, "err"); }
}
async function toggleRoomRec() {
if (roomRec.rec) { await stopRoomRec(true); return; }
var room = activeRoom();
if (!room || room.status !== "open") { banner("Нет открытой комнаты.", "warn"); return; }
if (mic.rec) { banner("Сначала завершите надиктовку.", "warn"); return; }
if (!navigator.mediaDevices || !window.MediaRecorder) {
banner("Этот браузер не умеет записывать звук.", "warn"); return;
}
try {
roomRec.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
banner("Микрофон недоступен: разрешите запись в настройках.", "warn"); return;
}
roomRec.chunks = [];
roomRec.roomId = room.id;
roomRec.startMs = Date.now();
roomRec.rec = new MediaRecorder(roomRec.stream);
roomRec.rec.ondataavailable = function (ev) { if (ev.data && ev.data.size) roomRec.chunks.push(ev.data); };
roomRec.rec.onstop = function () { sendRoomTrack(); };
// Кусок раз в 15 секунд: упади вкладка - хоть что-то уже собрано в памяти.
roomRec.rec.start(15000);
// Экран стараемся не дать погаснуть (не везде поддерживается - тогда просто
// просим не блокировать руками).
acquireLock(roomRec);
var btn = document.getElementById("rm-rec");
roomRec.timer = setInterval(function () {
var sec = Math.round((Date.now() - roomRec.startMs) / 1000);
var mm = Math.floor(sec / 60), ss = ("0" + (sec % 60)).slice(-2);
btn.innerHTML = recIndicatorHtml("Выключить микрофон (" + mm + ":" + ss + ")");
}, 1000);
btn.innerHTML = recIndicatorHtml("Выключить микрофон (0:00)");
startRecMeter(roomRec.stream, btn);
// Опрос комнаты: закрыли встречу - запись сама останавливается и уезжает.
roomRec.poll = setInterval(async function () {
try {
var d = await api("/api/rooms/" + encodeURIComponent(roomRec.roomId));
if (d.room && d.room.status !== "open") {
banner("Встречу завершили - отправляю вашу дорожку.", "ok");
await stopRoomRec(true);
await load();
}
} catch (e) {}
}, 10000);
renderRooms();
}
function cleanupRoomRec() {
if (roomRec.timer) { clearInterval(roomRec.timer); roomRec.timer = null; }
if (roomRec.poll) { clearInterval(roomRec.poll); roomRec.poll = null; }
if (roomRec.lock) { try { roomRec.lock.release(); } catch (e) {} roomRec.lock = null; }
if (roomRec.stream) { roomRec.stream.getTracks().forEach(function (t) { t.stop(); }); roomRec.stream = null; }
stopRecMeter();
var btn = document.getElementById("rm-rec");
if (btn) btn.textContent = "Включить микрофон";
}
var roomStopWaiter = null;
function stopRoomRec(send) {
return new Promise(function (resolve) {
if (!roomRec.rec) { cleanupRoomRec(); resolve(); return; }
roomStopWaiter = resolve;
roomRec.sendAfterStop = send !== false;
try { roomRec.rec.stop(); } catch (e) { roomRec.rec = null; cleanupRoomRec(); resolve(); }
});
}
async function sendRoomTrack() {
var blob = new Blob(roomRec.chunks, { type: (roomRec.rec && roomRec.rec.mimeType) || "audio/webm" });
var roomId = roomRec.roomId;
var startMs = roomRec.startMs;
var doSend = roomRec.sendAfterStop !== false;
roomRec.rec = null; roomRec.chunks = []; roomRec.roomId = null;
cleanupRoomRec();
if (doSend && blob.size && roomId) {
banner("Отправляю вашу дорожку…");
try {
var res = await fetch(API_BASE + "/api/rooms/" + encodeURIComponent(roomId) + "/track", {
method: "POST",
headers: Object.assign(authHeaders(), {
"Content-Type": blob.type || "audio/webm",
"X-Track-Start-Ms": String(startMs),
"X-Client-Now-Ms": String(Date.now()),
}),
body: blob,
});
var data = await res.json();
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
banner("Дорожка отправлена (" + Math.round(blob.size / 1024) + " КБ).", "ok");
} catch (e) {
banner("Дорожка не отправилась: " + e.message + ". Нажмите запись ещё раз - повторная дорожка заменит эту.", "err");
}
}
if (roomStopWaiter) { roomStopWaiter(); roomStopWaiter = null; }
renderRooms();
}
function closeRoom() {
// Завершение действует на ВСЕХ участников - подтверждаем, как удаление.
showConfirm("Завершить встречу для всех? Записи участников поедут в расшифровку, обратно комната не открывается.",
function () { doCloseRoom(); }, "Завершить");
}
async function doCloseRoom() {
var room = activeRoom();
if (!room) return;
// Своя запись первой: дорожка закрывающего не должна потеряться.
if (roomRec.rec) { await stopRoomRec(true); }
try {
var res = await fetch(API_BASE + "/api/rooms/" + encodeURIComponent(room.id) + "/close", {
method: "POST",
headers: authHeaders(),
});
var data = await res.json();
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
banner("Встреча завершена. Дорожки участников собираются - встреча появится в списке.", "ok");
await load();
} catch (e) { banner("Не закрылась: " + e.message, "err"); }
}
// Комната и конвейер встреч обновляются САМИ, пока есть за чем следить -
// разбор 12.08: "статус не обновляется, надо руками обновлять". Раз в 12
// секунд, только на видимой вкладке и только когда есть открытая комната или
// встреча в работе; в остальное время такт - бесплатная проверка условия.
var WATCH_MEETING_BUSY = { uploaded: 1, transcribing: 1, recognizing: 1, extracting: 1, naming: 1, awaiting_project: 1 };
setInterval(async function () {
if (state.demo || document.hidden) return;
var busyRoom = !!activeRoom();
var busyMeet = (state.meetings || []).some(function (m) { return WATCH_MEETING_BUSY[m.status]; });
if (!busyRoom && !busyMeet) return;
try {
var r = await Promise.all([
api("/api/rooms").catch(function () { return null; }),
api("/api/meetings").catch(function () { return null; }),
]);
if (r[0] && r[0].rooms) state.rooms = r[0].rooms;
if (r[1] && r[1].meetings) state.meetings = r[1].meetings;
renderRooms();
renderMeetings();
} catch (e) { /* фоновое обновление не имеет права мешать */ }
}, 12000);
// --------------------------- Вкладка Стримы ---------------------------
function renderStreamsTab() {
var box = document.getElementById("streams-overview");
if (!box) return;
var streams = state.streams || [];
box.innerHTML = streams.length
? streams.map(function (st, i) {
var projs = (state.projects || []).filter(function (p) { return p.stream_id === st.id; });
var open = (state.tasks || []).filter(function (t) {
return taskStreamId(t) === st.id && t.status !== "done" && t.status !== "cancelled";
}).length;
return '<div class="card"><div class="row"><b class="grow">' + esc(st.name) +
'</b> <span class="sub">проектов: ' + projs.length + " · открытых задач: " + open + "</span></div>" +
// Легенда стрима (разбор 12.08): контекст направления. Видит человек
// здесь, видит ИИ - при подсказке проекта в разборе надиктовки.
'<div class="sub" style="margin-top:6px">' +
(st.legend ? esc(st.legend) : "легенда не задана - о чём это направление, что здесь считается задачей") +
"</div>" +
(projs.length
? '<div class="meta" style="margin-top:6px">' +
projs.map(function (p) { return '<span class="tag">' + esc(p.name) + "</span>"; }).join("") +
"</div>"
: '<div class="sub" style="margin-top:6px">проектов пока нет</div>') +
'<div class="acts"><button class="btn ghost sm" onclick="goBoardByStream(\'' + esc(st.id) +
'\')">Задачи стрима</button>' +
'<button class="btn ghost sm" onclick="goProjectsByStream(\'' + esc(st.id) +
'\')">Проекты стрима</button>' +
'<button class="btn ghost sm" onclick="toggleStreamLegend(' + i + ')">Легенда</button></div>' +
'<div id="stl-' + i + '" style="display:none">' +
'<label>Легенда стрима</label>' +
'<textarea id="stl-text-' + i + '" class="grow" rows="2" ' +
'placeholder="О чём направление: цель, рамки, что считается задачей">' +
esc(st.legend || "") + "</textarea>" +
'<div class="acts" style="margin-top:6px">' +
'<button class="btn sm" onclick="saveStreamLegend(' + i + ')">Сохранить</button>' +
'<button class="btn ghost sm" data-mic="stl-text-' + i + '"><i class="mic-dot"></i>Надиктовать</button>' +
"</div>" +
"</div>" +
"</div>";
}).join("")
: '<div class="card"><div class="sub">Стримов пока нет - создайте первый ниже.</div></div>';
}
function toggleStreamLegend(i) {
var el = document.getElementById("stl-" + i);
if (el) el.style.display = el.style.display === "none" ? "" : "none";
}
async function saveStreamLegend(i) {
var st = (state.streams || [])[i];
var ta = document.getElementById("stl-text-" + i);
if (!st || !ta) return;
if (state.demo) { banner("В демо-режиме сохранение недоступно.", "warn"); return; }
try {
await api("/api/streams/" + encodeURIComponent(st.id), {
method: "PATCH",
body: JSON.stringify({ legend: ta.value.trim() }),
});
banner("Легенда стрима сохранена", "ok");
await load();
} catch (e) { banner("Не сохранилась: " + e.message, "err"); }
}
// Стрим -> доска: переключаем вкладку и ставим фильтр. Обратный путь очевиден
// (фильтр виден и сбрасывается руками), поэтому никакой магии состояния.
// Приглашение в комнату: ссылка на приложение плюс короткая инструкция.
// Отдельной "ссылки на комнату" нет и не нужно - комната одна на пространство,
// участник просто открывает приложение и включает микрофон.
function copyRoomInvite() {
var room = activeRoom();
var text = "Идёт запись встречи" + (room ? " «" + room.title + "»" : "") +
". Открой todoiter, вкладка Встречи - «Включить микрофон», и говори как обычно. " +
"Телефон положи рядом, приложение не сворачивай.";
try {
navigator.clipboard.writeText(text);
banner("Приглашение скопировано - вставьте в чат встречи.", "ok");
} catch (e) {
banner("Скопируйте вручную: " + text, "warn");
}
}
function goProjectsByStream(streamId) {
switchTab("projects");
var pf = document.getElementById("pf-stream");
if (pf) {
// Наполнение случится в renderProjects, если список ещё пустой.
renderProjects();
pf.value = streamId;
}
renderProjects();
}
function goBoardByStream(streamId) {
switchTab("board");
state.cameFrom = "streams";
var f = document.getElementById("f-stream");
if (f) f.value = streamId;
render();
}
function goBackFrom() {
var to = state.cameFrom === "people" ? "people" : "streams";
state.cameFrom = null;
// Фильтр перехода снимаем - вернувшись, человек ждёт полную доску.
document.getElementById("f-assignee").value = "all";
var fs2 = document.getElementById("f-stream");
if (fs2.style.display !== "none") fs2.value = "all";
document.getElementById("f-search").value = "";
render();
switchTab(to);
}
// Человек -> доска: если он есть в фильтре исполнителей, включаем фильтр,
// иначе ищем по имени (ловит и описание - "передал Пете").
function goBoardByPerson(name) {
switchTab("board");
state.cameFrom = "people";
var fa = document.getElementById("f-assignee");
var has = false;
if (fa) {
for (var i = 0; i < fa.options.length; i += 1) {
if (fa.options[i].value === name) { has = true; break; }
}
fa.value = has ? name : "all";
}
var fs = document.getElementById("f-search");
if (fs) fs.value = has ? "" : name;
render();
}
// --------------------------- Вкладка Люди ---------------------------
//
// Владелец дословно: "должна быть отдельная закладка люди... переименовать...
// логины всех этих людей, чтобы я мог кликнуть и посмотреть, кто это".
function renderPeopleTab() {
var box = document.getElementById("people-all");
if (!box) return;
if (state.mergeFrom === undefined) state.mergeFrom = null;
var people = state.allPeople || [];
// Красный счётчик "неразобранных" - людей без единого проекта.
var badge = document.getElementById("people-badge");
if (badge) {
var loose = people.filter(function (h) { return !(h.projects || []).length; }).length;
badge.style.display = loose > 0 ? "" : "none";
badge.textContent = loose > 99 ? "99+" : String(loose);
}
box.innerHTML = people.length
? people.map(function (h, i) {
var login = h.username
? '<a href="https://t.me/' + esc(h.username) + '" target="_blank">@' + esc(h.username) + "</a>"
: '<span class="sub">логин неизвестен</span>';
var projs = (h.projects || []).map(function (n) {
return '<span class="tag">' + esc(n) + "</span>";
}).join("");
return '<div class="card">' +
'<div class="row"><b class="grow">' + esc(h.name) + "</b>" +
(h.inTracker ? '<span class="tag hi" title="Человек заходил в todoiter - ему приходят уведомления">есть в todoiter</span>' : "") + "</div>" +
'<div class="sub" style="margin-top:4px">' + login + "</div>" +
(projs ? '<div class="meta" style="margin-top:6px">' + projs + "</div>"
: '<div class="sub" style="margin-top:6px">ни в одном проекте - добавьте ниже</div>') +
'<div class="acts"><button class="btn ghost sm" onclick="togglePersonEdit(' + i + ')">Изменить</button>' +
'<button class="btn ghost sm" onclick="goBoardByPerson(' + JSON.stringify(h.name || "").replace(/"/g, """) + ')">Задачи на доске</button>' +
(state.mergeFrom === null || state.mergeFrom === undefined
? '<button class="btn ghost sm" onclick="startMerge(' + i + ')">Объединить…</button>'
: (state.mergeFrom === i
? '<button class="btn ghost sm" onclick="cancelMerge()">Отменить объединение</button>'
: '<button class="btn sm" onclick="finishMerge(' + i + ')">Объединить с этой</button>')) +
"</div>" +
'<div id="pe-' + i + '" style="display:none">' +
'<label>Имя (как показывать в задачах и отчётах)</label>' +
'<input id="pe-name-' + i + '" value="' + esc(h.name) + '" />' +
'<label>Логин в Telegram (без @)</label>' +
'<input id="pe-login-' + i + '" value="' + esc(h.username || "") + '" placeholder="например ternovaya" />' +
'<label>Проекты</label>' +
'<select id="pe-proj-' + i + '"><option value="">Проект</option>' +
(state.projects || []).filter(function (pr) { return pr.kind !== "one_on_one"; })
.map(function (pr) { return '<option value="' + esc(pr.id) + '">' + esc(pr.name) + "</option>"; }).join("") +
"</select>" +
'<div class="acts" style="margin-top:8px">' +
'<button class="btn sm" onclick="savePerson(' + i + ')">Сохранить</button>' +
"</div>" +
"</div>" +
"</div>";
}).join("")
: '<div class="card"><div class="sub">Пока никого: люди появляются из групп, личной переписки и участников.</div></div>';
}
// ---- Объединение двух карточек в одного человека ---------------------------
// Владелец: "я и Алексей - это один человек, дай кнопку объединить: клик,
// выбери вторую карточку, подтвердить". Имя, логин и вход берутся у карточки,
// у которой они есть; строки людей по проектам склеиваются под одним человеком.
function startMerge(i) {
state.mergeFrom = i;
banner("Теперь нажмите «Объединить с этой» на второй карточке этого же человека.", "warn");
renderPeopleTab();
}
function cancelMerge() {
state.mergeFrom = null;
renderPeopleTab();
}
async function finishMerge(j) {
var a = (state.allPeople || [])[state.mergeFrom];
var b = (state.allPeople || [])[j];
state.mergeFrom = null;
if (!a || !b) { renderPeopleTab(); return; }
// Хозяин пары - карточка с входом в todoiter, иначе первая выбранная.
var keep = a.userId ? a : (b.userId ? b : a);
var other = keep === a ? b : a;
if (!keep.userId && !other.userId) {
banner("Пока объединяю только пары, где у одной из карточек есть вход в todoiter. " +
"У этих двух его нет - переименуйте их в одно имя, и карточки склеятся сами.", "warn");
renderPeopleTab();
return;
}
var ok = await new Promise(function (resolve) {
showConfirm("Объединить «" + other.name + "» и «" + keep.name + "»? Останется «" +
keep.name + "»" + (keep.username ? " @" + keep.username : "") +
", все задачи и проекты обеих карточек будут считаться его.",
function () { resolve(true); }, "Объединить", function () { resolve(false); });
});
if (!ok) { renderPeopleTab(); return; }
try {
await api("/api/people/rename", {
method: "PATCH",
body: JSON.stringify({
name: keep.name,
username: keep.username || other.username || "",
personIds: (keep.personIds || []).concat(other.personIds || []),
linkUserId: keep.userId,
}),
});
banner("Объединено: теперь это один человек - " + keep.name, "ok");
await load();
} catch (e) { banner("Не получилось объединить: " + e.message, "err"); }
}
function togglePersonEdit(i) {
var box = document.getElementById("pe-" + i);
if (box) box.style.display = box.style.display === "none" ? "" : "none";
}
async function savePerson(i) {
var h = (state.allPeople || [])[i];
if (!h) return;
var name = (document.getElementById("pe-name-" + i).value || "").trim();
var login = (document.getElementById("pe-login-" + i).value || "").trim().replace(/^@/, "");
var projId = document.getElementById("pe-proj-" + i).value;
try {
if ((name && name !== h.name) || (login && login !== (h.username || "")) ) {
await api("/api/people/rename", {
method: "PATCH",
body: JSON.stringify({
name: name || h.name, username: login,
personIds: h.personIds || [], userId: h.userId || null,
}),
});
}
if (projId) {
var body = { displayName: name || h.name };
if (h.tgId) body.telegramUserId = Number(h.tgId);
if (h.userId) body.userId = h.userId;
await api("/api/projects/" + encodeURIComponent(projId) + "/people", {
method: "POST", body: JSON.stringify(body),
});
}
banner("Сохранено", "ok");
await load();
} catch (e) { banner("Не сохранилось: " + e.message, "err"); }
}
// ------------------------------ Встречи ------------------------------
function fillMeetingProjects() {
const mp = document.getElementById("mt-project");
if (!mp) return;
const ms = document.getElementById("mt-stream");
const chosen = ms ? ms.value : "all";
const prev = mp.value;
mp.innerHTML = state.projects.filter(function (p) {
if (p.kind === "one_on_one") return false;
if (chosen !== "all" && p.stream_id !== chosen) return false;
return true;
}).map(function (p) {
return '<option value="' + esc(p.id) + '">' + esc(p.name) + "</option>";
}).join("") || '<option value="">нет проектов</option>';
if ([...mp.options].some(function (o) { return o.value === prev; })) mp.value = prev;
}
async function uploadMeeting() {
const title = document.getElementById("mt-title").value.trim();
const projectId = document.getElementById("mt-project").value;
const fileInput = document.getElementById("mt-file");
const file = fileInput.files && fileInput.files[0];
if (!title) { needField("mt-title", "Название встречи - без него запись не загрузится"); return; }
if (!projectId) { needField("mt-project", "Выберите проект встречи"); return; }
if (!file) { banner("Выберите файл записи.", "warn"); return; }
if (file.size > 130 * 1024 * 1024) { banner("Файл больше 130 МБ - это дольше полутора часов, потолок этапа.", "warn"); return; }
if (state.demo) { banner("В демо-режиме загрузка недоступна.", "warn"); return; }
const btn = document.getElementById("mt-upload");
btn.disabled = true; btn.textContent = "Загружаю…";
try {
// Файл уходит телом запроса как есть - без обёрток, которые могли бы его
// испортить. Название - в заголовке, поэтому кодируется (кириллица).
const res = await fetch(API_BASE + "/api/meetings", {
method: "POST",
headers: {
"x-telegram-init-data": initData,
"Content-Type": file.type || "application/octet-stream",
"X-Meeting-Title": encodeURIComponent(title),
"X-Meeting-Project": projectId,
},
body: file,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
document.getElementById("mt-title").value = "";
fileInput.value = "";
banner("Запись загружена - расшифровка началась, статус в списке ниже.", "ok");
await load();
} catch (e) {
banner("Не удалось загрузить: " + e.message, "err");
} finally {
btn.disabled = false; btn.textContent = "Загрузить запись";
}
}
// Человеческое ожидание по статусу: "в очереди" без срока выглядит зависшим.
function meetingEta(status) {
if (status === "queued" || status === "uploaded") return " · начнётся в течение минуты";
if (status === "transcribing" || status === "recognizing") return " · обычно 5-15 минут, длинные записи до часа";
if (status === "extracting" || status === "naming") return " · ещё пара минут";
return "";
}
function renderMeetings() {
const box = document.getElementById("meetings-list");
if (!box) return;
const items = state.meetings || [];
// Подпись состояния: перерисовываемся только при изменениях - фоновое
// обновление не должно схлопывать открытые транскрипты.
var sig = items.map(function (m) { return m.id + ":" + m.status + ":" + m.statusLabel; }).join("|");
if (box.dataset.sig === sig) return;
box.dataset.sig = sig;
box.innerHTML = items.length
? items.map(function (m) {
var mins = m.durationSec ? Math.round(m.durationSec / 60) + " мин · " : "";
var cls = m.status === "done" ? "hi" : (m.status === "failed" ? "warn" : "");
// Транскрипт появляется, когда куски уже в базе (extracting и дальше).
var canRead = m.status === "done" || m.status === "extracting";
return '<div class="card"><div><b>' + esc(m.title) + "</b>" +
(m.projectName ? ' <span class="tag">' + esc(m.projectName) + "</span>" : "") + "</div>" +
'<div class="meta" style="margin-top:6px"><span class="tag ' + cls + '">' +
esc(mins + m.statusLabel) + "</span></div>" +
(meetingEta(m.status) ? '<div class="sub">' + esc(meetingEta(m.status).replace(" · ", "")) + "</div>" : "") +
(canRead
? '<div class="acts"><button class="btn ghost sm" data-mtr="' + esc(m.id) + '">Транскрипт</button></div>' +
'<div id="mtr-' + esc(m.id) + '"></div>'
: "") +
"</div>";
}).join("")
: "";
}
// Транскрипт разворачивается по кнопке и грузится один раз - повторное нажатие
// сворачивает. Таймкод у куска помогает сослаться на место в разговоре.
async function toggleTranscript(meetingId) {
var box = document.getElementById("mtr-" + meetingId);
if (!box) return;
if (box.innerHTML) { box.innerHTML = ""; return; }
box.innerHTML = '<div class="sub">загружаю…</div>';
try {
var data = await api("/api/meetings/" + encodeURIComponent(meetingId) + "/transcript");
box.innerHTML = (data.chunks || []).map(function (c) {
var t = c.offsetSec === null ? "" : "<b>" + esc(fmtOffset(c.offsetSec)) + "</b> ";
return '<div class="sub" style="margin-top:6px">' + t + esc(c.text) + "</div>";
}).join("") || '<div class="sub">пусто</div>';
} catch (e) {
box.innerHTML = '<div class="sub">не загрузился: ' + esc(e.message) + "</div>";
}
}
function fmtOffset(sec) {
var s = Math.max(0, Math.round(sec));
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = String(s % 60);
if (r.length < 2) r = "0" + r;
return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + r : m + ":" + r;
}
async function createStream() {
const name = document.getElementById("st-name").value.trim();
if (!name) { banner("Напишите название стрима.", "warn"); return; }
if (state.demo) { banner("В демо-режиме создание недоступно.", "warn"); return; }
try {
await api("/api/streams", { method: "POST", body: JSON.stringify({ name: name }) });
document.getElementById("st-name").value = "";
banner("Стрим создан", "ok");
await load();
} catch (e) { banner("Не удалось создать стрим: " + e.message, "err"); }
}
async function changeProjectStream(projectId, streamId) {
if (state.demo) { banner("В демо-режиме перенос недоступен.", "warn"); return; }
try {
await api("/api/projects/" + encodeURIComponent(projectId) + "/stream", {
method: "PATCH",
body: JSON.stringify({ streamId: streamId || null }),
});
banner(streamId ? "Проект перенесён в стрим" : "Проект отвязан от стрима", "ok");
await load();
} catch (e) { banner("Не удалось перенести: " + e.message, "err"); }
}
async function mapChannel() {
const channelId = document.getElementById("c-chat").value;
const projectId = document.getElementById("c-project").value;
if (!channelId) { banner("Сначала добавьте бота в группу - она появится в списке.", "warn"); return; }
if (!projectId) { banner("Сначала создайте проект.", "warn"); return; }
if (state.demo) { banner("В демо-режиме привязка недоступна.", "warn"); return; }
try {
await api("/api/channels", {
method: "POST",
body: JSON.stringify({ channelId: channelId, projectId: projectId }),
});
banner("Группа привязана к проекту", "ok");
await load();
} catch (e) { banner("Не удалось привязать группу: " + e.message, "err"); }
}
// ------------------------------ Профиль ------------------------------
const ROLE_LABEL = { leader: "Лидер", pm: "Менеджер", executor: "Исполнитель" };
// Что роль значит - по просьбе владельца: "если есть роли, дай описание".
const ROLE_HINT = {
leader: "видит всё пространство и настраивает его",
pm: "ведёт свои проекты и людей в них",
executor: "видит и ведёт свои задачи",
};
const ID_LABEL = { telegram: "Telegram", phone: "Телефон", email: "Почта" };
// --------------------- Настройки пространства ---------------------
function minToTime(min) {
var h = Math.floor(min / 60), m = min % 60;
return ("0" + h).slice(-2) + ":" + ("0" + m).slice(-2);
}
function timeToMin(v) {
var p = /^(\d{1,2}):(\d{2})$/.exec(v || "");
if (!p) return null;
var min = Number(p[1]) * 60 + Number(p[2]);
return min >= 0 && min < 1440 ? min : null;
}
var TZ_OPTIONS = [
[120, "UTC+2 (Калининград)"], [180, "UTC+3 (Москва)"], [240, "UTC+4 (Самара)"],
[300, "UTC+5 (Екатеринбург)"], [360, "UTC+6 (Омск)"], [420, "UTC+7 (Красноярск)"],
[480, "UTC+8 (Иркутск)"], [540, "UTC+9 (Якутск)"], [600, "UTC+10 (Владивосток)"],
[660, "UTC+11 (Магадан)"], [720, "UTC+12 (Камчатка)"],
];
function renderOrgSettings() {
var s = state.orgSettings;
if (!s) return;
document.getElementById("os-evening").value = minToTime(s.digestEveningMin);
document.getElementById("os-morning").value = minToTime(s.digestMorningMin);
var tzSel = document.getElementById("os-tz");
var known = TZ_OPTIONS.some(function (o) { return o[0] === s.tzOffsetMin; });
tzSel.innerHTML = TZ_OPTIONS.map(function (o) {
return '<option value="' + o[0] + '"' + (o[0] === s.tzOffsetMin ? " selected" : "") + ">" + o[1] + "</option>";
}).join("") + (known ? "" : '<option value="' + s.tzOffsetMin + '" selected>UTC' +
(s.tzOffsetMin >= 0 ? "+" : "") + (s.tzOffsetMin / 60) + "</option>");
// Получатель отчётов: участники с Telegram; пусто = первый лидер.
var rc = document.getElementById("os-recipient");
var withTg = (state.members || []).filter(function (m) { return m.telegramUserId; });
rc.innerHTML = '<option value="">лидер (по умолчанию)</option>' + withTg.map(function (m) {
var name = (m.displayName || "без имени") + (m.role === "leader" ? " · лидер" : "");
var sel = s.reportRecipientTg && String(m.telegramUserId) === String(s.reportRecipientTg);
return '<option value="' + esc(String(m.telegramUserId)) + '"' + (sel ? " selected" : "") + ">" +
esc(name) + "</option>";
}).join("");
}
async function saveOrgSettings() {
if (state.demo) { banner("В демо-режиме настройки недоступны.", "warn"); return; }
var evening = timeToMin(document.getElementById("os-evening").value);
var morning = timeToMin(document.getElementById("os-morning").value);
if (evening === null || morning === null) { banner("Время в виде 19:30.", "warn"); return; }
var body = {
digestEveningMin: evening,
digestMorningMin: morning,
tzOffsetMin: Number(document.getElementById("os-tz").value),
};
var rc = document.getElementById("os-recipient").value;
body.reportRecipientTg = rc ? Number(rc) : null;
try {
await api("/api/org/settings", { method: "PATCH", body: JSON.stringify(body) });
banner("Сохранено: расписания сводок пересчитаны сразу.", "ok");
await load();
} catch (e) { banner("Не удалось сохранить: " + e.message, "err"); }
}
function renderMyProfile() {
var p = state.myProfile;
if (!p) return;
document.getElementById("pr-about").value = p.about || "";
document.getElementById("pr-goals").value = p.goals || "";
document.getElementById("pr-eval").value = p.evaluation || "";
}
async function saveProfile() {
if (state.demo) { banner("В демо-режиме профиль недоступен.", "warn"); return; }
try {
await api("/api/me/profile", { method: "PUT", body: JSON.stringify({
about: document.getElementById("pr-about").value,
goals: document.getElementById("pr-goals").value,
evaluation: document.getElementById("pr-eval").value,
}) });
banner("Профиль сохранён - советник будет опираться на него.", "ok");
} catch (e) { banner("Не удалось сохранить: " + e.message, "err"); }
}
function renderProfile() {
renderMyProfile();
renderOrgSettings();
const card = document.getElementById("profile-card");
if (!state.me) {
card.innerHTML = '<div class="sub">' + (state.demo ? "Недоступно в демо-режиме." : "Нет данных.") + "</div>";
return;
}
const m = state.me;
const ids = (m.identities || []).map(function (i) {
return '<span class="tag ' + (i.verified ? "hi" : "warn") + '">' +
esc(ID_LABEL[i.type] || i.type) + (i.verified ? " привязан" : " не подтверждён") + "</span>";
}).join("");
card.innerHTML =
"<div><b>" + esc(m.displayName || "без имени") + "</b></div>" +
'<div class="sub">Роль: ' + esc(ROLE_LABEL[m.role] || m.role || "не назначена") +
(ROLE_HINT[m.role] ? " - " + esc(ROLE_HINT[m.role]) : "") + "</div>" +
'<div class="meta" style="margin-top:8px">' + ids + "</div>" +
"<label>Уведомления о новом входе на почту</label>" +
'<select id="notif-toggle" onchange="toggleNotifications()">' +
'<option value="on"' + (m.emailLoginNotifications ? " selected" : "") + ">Включены</option>" +
'<option value="off"' + (m.emailLoginNotifications ? "" : " selected") + ">Отключены</option>" +
"</select>";
}
async function toggleNotifications() {
const on = document.getElementById("notif-toggle").value === "on";
try {
await api("/api/me", { method: "PATCH", body: JSON.stringify({ emailLoginNotifications: on }) });
if (state.me) state.me.emailLoginNotifications = on;
banner(on ? "Уведомления о входе включены" : "Уведомления о входе отключены", "ok");
} catch (e) { banner("Не удалось сохранить: " + e.message, "err"); }
}
async function startVerify() {
const type = document.getElementById("v-type").value;
const identifier = document.getElementById("v-ident").value.trim();
if (!identifier) { banner("Укажите адрес или номер.", "warn"); return; }
if (state.demo) { banner("В демо-режиме подтверждение недоступно.", "warn"); return; }
try {
await api("/api/identities/" + type + "/start", { method: "POST", body: JSON.stringify({ identifier: identifier }) });
document.getElementById("v-confirm").className = "";
banner("Код отправлен. Введите его ниже.", "ok");
} catch (e) { banner("Не удалось выслать код: " + e.message, "err"); }
}
async function confirmVerify() {
const type = document.getElementById("v-type").value;
const identifier = document.getElementById("v-ident").value.trim();
const code = document.getElementById("v-code").value.trim();
if (!code) return;
try {
await api("/api/identities/" + type + "/confirm", {
method: "POST",
body: JSON.stringify({ identifier: identifier, code: code }),
});
document.getElementById("v-confirm").className = "hidden";
document.getElementById("v-code").value = "";
banner("Подтверждено", "ok");
await load();
} catch (e) { banner("Код не принят: " + e.message, "err"); }
}
async function addMember() {
const tgId = parseInt(document.getElementById("m-tg").value.trim(), 10);
const displayName = document.getElementById("m-name").value.trim();
// Роли убраны из интерфейса (разворот владельца 12.08: "роли лишние" - есть
// владелец доски и приглашённые). Сервер пока ждёт роль - шлём нейтрального
// участника; доступ настраивается отдельно галочками видимости.
const role = "executor";
if (isNaN(tgId)) { banner("Telegram ID должен быть числом.", "warn"); return; }
if (state.demo) { banner("В демо-режиме добавление недоступно.", "warn"); return; }
try {
await api("/api/members", {
method: "POST",
body: JSON.stringify({ telegramUserId: tgId, displayName: displayName, role: role }),
});
document.getElementById("m-tg").value = "";
document.getElementById("m-name").value = "";
banner("Участник добавлен", "ok");
} catch (e) { banner("Не удалось добавить: " + e.message, "err"); }
}
// ============================================================================
// Надиктовка: микрофон -> текст в поле.
//
// ПОЧЕМУ ТЕКСТ ПОПАДАЕТ В ПОЛЕ, А НЕ СРАЗУ СОХРАНЯЕТСЯ. Распознавание ошибается,
// особенно на именах и терминах. Человек видит расшифровку, правит её и
// нажимает обычную кнопку сохранения - ту же, что и для набранного руками.
// Тот же принцип, что с извлечёнными задачами: показать и дать поправить, а не
// подставить молча.
//
// Запись идёт средствами браузера, без библиотек. Дорожка останавливается сразу
// после отправки: горящий индикатор микрофона, когда запись уже не идёт, - это
// то, за что приложения справедливо не любят.
// ============================================================================
var mic = { rec: null, chunks: [], targetId: null, stream: null, lock: null };
function micButtonFor(targetId) {
return document.querySelector('[data-mic="' + targetId + '"]');
}
function setMicLabel(targetId, text, busy) {
var btn = micButtonFor(targetId);
if (!btn) return;
btn.textContent = text;
btn.disabled = !!busy;
}
async function micToggle(targetId) {
// Второе нажатие останавливает запись - кнопка одна, состояние на ней видно.
if (mic.rec && mic.targetId === targetId) { stopMic(); return; }
if (mic.rec) { banner("Уже идёт запись в другом поле.", "warn"); return; }
if (state.demo) { banner("В демо-режиме надиктовка недоступна.", "warn"); return; }
if (!navigator.mediaDevices || !window.MediaRecorder) {
banner("Этот браузер не умеет записывать звук.", "warn");
return;
}
try {
mic.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
// Отказ в доступе - самая частая причина; объясняем прямо, а не "ошибка".
banner("Микрофон недоступен: разрешите запись в настройках браузера.", "warn");
return;
}
mic.chunks = [];
mic.targetId = targetId;
mic.rec = new MediaRecorder(mic.stream);
mic.rec.ondataavailable = function (ev) { if (ev.data && ev.data.size) mic.chunks.push(ev.data); };
mic.rec.onstop = function () { sendRecording(targetId); };
mic.rec.start();
// Пока говорят - экран не гаснет: погасший вебвью обрывает надиктовку.
acquireLock(mic);
var micBtn = document.querySelector('[data-mic="' + targetId + '"]');
if (micBtn) { micBtn.innerHTML = recIndicatorHtml("Остановить"); micBtn.disabled = false; }
startRecMeter(mic.stream, micBtn);
}
// --- Индикатор записи: точка в кнопке + бегущая волна громкости ---------------
// Владелец дословно: "красный кружочек... и волна, бежит слева направо, громче
// говорю - побольше". Точка пульсирует в кнопке (CSS), волна рисуется на
// канвасе рядом: свежий столбик появляется слева и уезжает вправо. Если
// AudioContext недоступен (старый вебвью) - остаётся точка.
var recMeter = { ctx: null, raf: 0, canvas: null, samples: [] };
function recIndicatorHtml(label) {
return '<span class="rec-dot"></span>' + label;
}
function attachWave(afterEl) {
removeWave();
var c = document.createElement("canvas");
c.className = "rec-wave";
c.width = 220; c.height = 30;
afterEl.insertAdjacentElement("afterend", c);
recMeter.canvas = c;
return c;
}
function removeWave() {
if (recMeter.canvas) { try { recMeter.canvas.remove(); } catch (e) {} recMeter.canvas = null; }
recMeter.samples = [];
}
function startRecMeter(stream, btn) {
stopRecMeter();
if (btn) attachWave(btn);
try {
var Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
recMeter.ctx = new Ctx();
var srcNode = recMeter.ctx.createMediaStreamSource(stream);
var an = recMeter.ctx.createAnalyser();
an.fftSize = 512;
srcNode.connect(an);
var buf = new Uint8Array(an.fftSize);
function tick() {
var c = recMeter.canvas;
if (c) {
an.getByteTimeDomainData(buf);
var peak = 0;
for (var i = 0; i < buf.length; i++) {
var d = Math.abs(buf[i] - 128);
if (d > peak) peak = d;
}
// Свежий отсчёт - СЛЕВА, старые уезжают вправо.
// Корень поднимает тихое: шёпот виден, крик не зашкаливает.
recMeter.samples.unshift(Math.min(1, Math.sqrt(peak / 48)));
if (recMeter.samples.length > 72) recMeter.samples.length = 72;
var g = c.getContext("2d");
g.clearRect(0, 0, c.width, c.height);
g.fillStyle = "#e53935";
var mid = c.height / 2;
for (var x = 0; x < recMeter.samples.length; x++) {
var h = Math.max(2, recMeter.samples[x] * (c.height - 4));
g.fillRect(x * 3, mid - h / 2, 2, h);
}
}
recMeter.raf = requestAnimationFrame(tick);
}
tick();
} catch (e) { /* без волны, но с точкой */ }
}
function stopRecMeter() {
if (recMeter.raf) { cancelAnimationFrame(recMeter.raf); recMeter.raf = 0; }
if (recMeter.ctx) { try { recMeter.ctx.close(); } catch (e) {} recMeter.ctx = null; }
removeWave();
}
function stopMic() {
if (!mic.rec) return;
stopRecMeter();
try { mic.rec.stop(); } catch (e) {}
// Дорожку глушим сразу: иначе индикатор микрофона горит до перезагрузки.
if (mic.stream) { mic.stream.getTracks().forEach(function (t) { t.stop(); }); }
mic.stream = null;
if (mic.lock) { try { mic.lock.release(); } catch (e) {} mic.lock = null; }
}
// Экран не гаснет, пока идёт запись. Система отпускает wake lock при уходе
// вкладки в фон - слушаем release и, вернувшись на вкладку, берём заново.
function acquireLock(holder) {
if (!navigator.wakeLock) return;
try {
navigator.wakeLock.request("screen").then(function (l) {
holder.lock = l;
l.addEventListener("release", function () { if (holder.lock === l) holder.lock = null; });
}).catch(function () {});
} catch (e) {}
}
document.addEventListener("visibilitychange", function () {
if (document.visibilityState !== "visible") return;
if (callRec.rec && !callRec.lock) acquireLock(callRec);
if (mic.rec && !mic.lock) acquireLock(mic);
if (typeof roomRec !== "undefined" && roomRec.rec && !roomRec.lock) acquireLock(roomRec);
});
async function sendRecording(targetId) {
var blob = new Blob(mic.chunks, { type: (mic.rec && mic.rec.mimeType) || "audio/webm" });
mic.rec = null;
mic.chunks = [];
mic.targetId = null;
if (!blob.size) { setMicLabel(targetId, "Надиктовать", false); return; }
setMicLabel(targetId, "Расшифровываю…", true);
try {
var res = await fetch(API_BASE + "/api/voice/transcribe", {
method: "POST",
headers: Object.assign(authHeaders(), { "Content-Type": blob.type || "audio/webm" }),
body: blob,
});
var data = await res.json();
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
if (data.status !== "ok" || !data.text) {
// Причина всегда конкретная: нет ключей, длинно, служба легла. Показываем
// её словами - иначе человек будет думать, что виноват его микрофон.
banner(data.error || "Не удалось разобрать запись.", "warn");
setMicLabel(targetId, "Надиктовать", false);
return;
}
var field = document.getElementById(targetId);
if (field) {
// Дописываем, а не затираем: человек мог начать печатать до надиктовки.
field.value = field.value ? field.value.trim() + " " + data.text : data.text;
field.focus();
}
if (targetId === "new-title") {
state.dictVoice = true;
if (dictPreview) {
// Додиктовка из предпросмотра: дополнение уже в поле - разбираем заново.
banner("Додиктовано - разбираю заново…", "ok");
previewDictate(dictPreview.mode);
} else {
// Быстрый вопрос владельца: записать дословно или сформулировать?
var mrow = document.getElementById("dict-mode");
if (mrow) mrow.style.display = "";
banner("Расшифровано - выберите: сформулировать или записать дословно", "ok");
}
} else {
banner("Расшифровано - проверьте текст и отправьте", "ok");
}
} catch (e) {
banner("Расшифровка не удалась: " + e.message, "err");
}
setMicLabel(targetId, "Надиктовать", false);
}
// Делегирование: кнопки микрофона рисуются и в карточке задачи, и на вкладке
// проектов, поэтому слушаем документ целиком, а обработчик один.
document.addEventListener("click", function (ev) {
if (!ev.target.closest) return;
var micBtn = ev.target.closest("[data-mic]");
if (micBtn) {
ev.preventDefault();
micToggle(micBtn.getAttribute("data-mic"));
return;
}
var delBtn = ev.target.closest("[data-chdel]");
if (delBtn) {
ev.preventDefault();
removeChannel(delBtn.getAttribute("data-chdel"));
}
});
// Вход по ссылке рекордера: человек пришёл ЗАПИСЫВАТЬ ЗВОНОК, а не смотреть
// доску - сразу открываем нужную вкладку и подводим к кнопке записи.
load().then(function () {
if (recordToken && !initData && !state.demo) {
// Встречи и рекордер переехали с Проектов на свою вкладку (порция 11) -
// ссылка обязана вести туда же, иначе человек попадает на пустые Проекты
// без кнопки записи (его живой случай 12.08).
switchTab("meetings");
var rc = document.getElementById("rm-call");
if (rc) {
rc.scrollIntoView({ block: "center" });
banner("Вы вошли по ссылке записи. Кнопка «Записать звонок» - ниже, рядом подсказка.", "ok");
}
}
});
</script>
</body>
</html>
Sitemap
Кол-во: 0
XML-карта сайта для поисковиков
?
Sitemap.xml помогает поисковику быстрее находить и индексировать страницы. Особенно важен для крупных сайтов и новых страниц, на которые ещё нет входящих ссылок.
Robots.txt не содержит ссылку на карту сайта. Рекомендуется добавить карту сайта и указать ссылку на нее в robots.txt.
Внутренние ссылки
Не найдено
Ссылки на другие страницы своего сайта
?
Внутренние ссылки распределяют ссылочный вес между страницами и помогают поисковику обходить сайт. Пустые анкоры и ссылки на запрещённые robots.txt страницы — типичные ошибки.
Внутренних ссылок на странице 0 оптимально.
Внешние ссылки
Не найдено
Ссылки на сторонние сайты
?
Исходящие внешние ссылки передают часть ссылочного веса на чужие сайты. Ссылки на авторитетные ресурсы безопасны; ссылки на мусорные сайты могут навредить репутации страницы.
Внешних ссылок на странице не найдено.
Конкуренты Готовность: 0%
Конкуренты в Яндексе
Кол-во: 0
Топ сайтов-конкурентов в Яндексе
?
Сайты, чаще всего появляющиеся в ТОПе Яндекса по запросам из семантического ядра этой страницы.
Мы не нашли у вас конкурентов в Яндексе. Сайт или очень молодой или плохо продвигается.
Конкурентов в ТОП-10 Яндекса не нашлось.
Конкуренты в Google
Кол-во: 0
Топ сайтов-конкурентов в Google
?
Сайты, чаще всего появляющиеся в ТОПе Google по запросам из семантического ядра этой страницы.
Конкуренты в Google тоже не найдены. Займитесь продвижением сайта!
Конкурентов в ТОП-10 Google не нашлось.
ФЗ-152: ПД Готовность: 40%
РКН
ОК
Сайт не заблокирован
Контакты
https://todoiter.ru/contacts/
Страница с контактами найдена
Политика ПД
https://todoiter.ru/policy/
Страница политики обработки ПД найдена
Согласие
https://todoiter.ru/personal/
Страница согласия на обработку ПД найдена
Чекбокс согласия
Не найден
Чекбокс согласия на обработку ПД в формах сайта не найден
Счётчики до согласия
ОК
Счётчики аналитики не собирают данные до согласия пользователя
ЗоЗПП: права потребителей Готовность: 100%
Нарушения
Не выявлены
Признаков дистанционной продажи товаров (интернет-магазина) не обнаружено — требования ЗоЗПП о раскрытии информации продавца к сайту не применяются. Нарушений нет.
ФЗ-149: рекомендательные технологии Готовность: 100%
Нарушения
Не выявлены
Рекомендательные блоки («с этим покупают», «похожие товары» и т.п.) на сайте не обнаружены — требования ст. 10.7 ФЗ-149 к сайту не применяются. Нарушений нет.
ФЗ-38: реклама Готовность: 100%
Нарушения
Не выявлены
Рекламных тематик с обязательными оговорками (медицина, БАД, кредиты и займы, новостройки) на сайте не обнаружено. Нарушений нет.
ФЗ-436: защита детей Готовность: 100%
Нарушения
Не выявлены
Признаков информационной продукции (новости, видео, книги, игры, курсы) не обнаружено — обязательная возрастная маркировка по ФЗ-436 сайту не требуется. Нарушений нет.
Вердикт
Выполнен подробный анализ сайта todoiter.ru: готовность на 50%. Чтобы выполнить все условия и попасть на первую страницу выдачи поисковых систем требуется:
Исправьте ошибки в мета-тегах.
Исправьте ошибки оптимизации.
Исправьте ошибки индексации.
Поделитесь с друзьями: