Skip to content

About

a personal project for study

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Долги и расписание (Telegram Mini App)

Личное приложение студента АлтГУ: учёт академических долгов и расписание своей группы вместе с расписанием преподавателей, у которых можно сдавать долги. Сделано как Telegram Mini App; пока работает как обычный сайт в браузере.

Главная идея расписания: у меня есть пары, а долги нужно сдавать конкретным преподавателям. Приложение показывает мой день и рядом пары нужных преподавателей: последняя пара до моего дня, пары внутри него (в «окне» или совпадающие с моей парой) и первая пара после. Так видно, к кому можно успеть, задержавшись или придя пораньше.

Состояние проекта

Что Статус
Экран долгов (работы, статус, общий прогресс) работает, данные хранятся в браузере
Расписание группы из .ics, недели, скрытие пар, параллельные пары работает
Расписания преподавателей, режимы «Моё / Преподаватели / Вместе», подбор пар работает
Выбор группы и преподавателя из списка сайта, поиск по фамилии, кнопка «Обновить» работает (проверено на живом сайте)
Хранение данных в Supabase, вход и разделение пользователей (RLS) не сделано
Telegram: подпись initData, открытие как Mini App не сделано
Выкладка на Vercel не сделано
Уведомления, чек-листы, ручные консультации отложено

Запуск

Нужен Node.js 20 или новее.

npm install
npm run dev      # сайт на http://localhost:5173, функции /api/* работают там же
npm test         # проверки логики и серверных функций
npm run build    # сборка и проверка типов

Расписание: на вкладке «Расписание» нажать «Выбрать группу из списка», затем «Обновить» при необходимости. Преподавателей добавляют в режиме «Вместе» или «Преподаватели».

Архитектура

Три слоя. Главное правило: вся логика, которую можно проверить, лежит в src/domain/ без React и без сети и покрыта тестами. Экраны только показывают и собирают действия, серверные функции только ходят на сайт расписания.

Браузер (React)                         Сервер (Vercel-функции)           Сайт расписания
┌──────────────────────────┐            ┌────────────────────┐           ┌────────────────┐
│ screens/  — экраны       │  /api/ics  │ api/ics.ts         │──────────▶│ asu.ru/timetable│
│ domain/   — логика       │ ─────────▶ │ api/directory.ts   │  .ics,    │  (.ics, списки, │
│ localStorage — данные    │  /api/…    │ api/search.ts      │  списки   │   поиск)        │
└──────────────────────────┘            │ src/server/site.ts │◀──────────└────────────────┘
                                        └────────────────────┘

Почему сервер, а не запросы из браузера

Сайт расписания не отдаёт данные браузерам чужих сайтов (CORS), а страницы со списками и поиском устроены хитро (см. ниже). Поэтому запросы на asu.ru делает наша серверная часть, а браузер говорит только со своим /api/*. Это же место, где соблюдаются «правила вежливости» к сайту.

Источники данных сайта

  • Расписание (.ics) — основной источник. Адрес строится из страницы: https://www.asu.ru<путь страницы>?file=<последний сегмент пути>.ics. В файле: время начала и конца (часовой пояс Asia/Krasnoyarsk), предмет, тип, преподаватель, аудитория. Дистанционные пары определяются по отсутствию номера аудитории.
  • Списки (институты → кафедры → преподаватели, институты → группы) и поиск — страницы сайта. Они приходят пустой оболочкой, а содержимое подгружает скрипт страницы вторым запросом с заголовком X-CS-ID (у каждой страницы свой токен, он есть в самом HTML). src/server/site.ts повторяет эти два шага: сначала оболочка, затем тот же адрес с токеном, cookie и Referer. Разбор — по ссылкам вида /timetable/lecturers/<институт>/<кафедра>/<id>/, а не по вёрстке.
  • Адрес поиска: /timetable/search/lecturers/?query=… (или students).

Правила вежливости к сайту

robots.txt сайта закрывает /timetable/; для личного проекта это решение принято сознательно, поэтому запросы сведены к минимуму:

  • Сайт запрашивается только по действию пользователя (клик по списку, ввод в поиск, кнопка «Обновить»), фонового обхода нет.
  • Кулдаун обновления одного источника — 30 минут; пауза 1,5 с между запросами подряд; поиск — от 3 символов и с задержкой 0,8 с после ввода.
  • Списки кэшируются в браузере на сутки, одинаковые запросы не повторяются.
  • При ответе 403 или 429 всё останавливается, пользователю подсказывается подождать или загрузить .ics файлом.
  • Редиректы не выполняются; запросы идут только на asu.ru/timetable/; заголовок User-Agent честный (debts-app/0.1 (personal student project)), под браузер приложение не маскируется.

Карта файлов

Первая строка каждого файла — его путь.

api/
  ics.ts          забрать .ics (только asu.ru/timetable/…?file=N.ics)
  directory.ts    список институтов / кафедр / преподавателей / групп
  search.ts       поиск преподавателя или группы по названию
src/
  App.tsx         нижняя панель вкладок: «Долги» и «Расписание» (экраны остаются в памяти)
  data/           backupIO.ts — запись и чтение копии; readDebts.ts — чтение долгов для показа; teachersStore.ts — общий список расписаний преподавателей
                  (его видят «Долги» и «Расписание»); addTeacher.ts — добавить по ссылке из поиска; fetchIcs.ts — запрос .ics через наш сервер
  domain/         логика без React и без сети
    debts.ts        модель долга, статус, прогресс
    schedule.ts     разбор .ics, недели, скрытие, границы дня, параллельные пары
    backup.ts       резервная копия: состав, сборка, проверка файла перед загрузкой
    notes.ts        заметки: заголовок, превью, порядок (закреплённые выше), поиск, дата
    insights.ts     сводки для боковой панели: ближайшая пара, загрузка недели, где застать преподавателей для долгов
    teachers.ts     подбор пар преподавателя к дню, тип календаря (группа/преподаватель)
    directory.ts    разбор списков и поиска сайта, пути
    sources.ts      проверка ссылок, кулдаун, «обновлено N мин назад»
    __fixtures__/   реальные образцы данных для тестов
  screens/
    DebtsScreen.tsx     экран долгов
    ScheduleScreen.tsx  расписание, режимы, обновление
    ScheduleAside.tsx   боковая сводка на широких экранах
    NotesScreen.tsx     заметки: список, поиск, редактирование в листе, привязка к долгу, закрепление
    DataSheet.tsx       лист «Резервная копия» (кнопка ⋯ на экране «Долги»)
    Picker.tsx          выбор из списка и поиск (внутри листа)
  ui/             общие элементы интерфейса
    Sheet.tsx       нижний лист: тянется пальцем, продолжает бросок, прерываем
    spring.ts       пружины, проекция импульса, «резина» у края (с тестами)
    Segmented.tsx   переключатель режимов; Toast.tsx / notice.ts — уведомления с «Отменить»
    icons.tsx       линейные иконки
  server/site.ts  загрузка страниц сайта в два шага (оболочка → токен → содержимое)
vite.config.ts    в разработке отдаёт /api/* теми же функциями, что будут на Vercel

Интерфейс и дизайн

Принцип: на виду только частое, остальное — на уровень глубже (в листе).

  • Долги: крупный итог («2 из 4»), ниже список по разделам «В работе / Уточнить требования / Закрыто». Детали долга (работы, требования, заметки, «Закрыть без работ», «Удалить») открываются листом по нажатию. Добавление — кнопка «+» вверху.

  • Расписание: вверху обновить (↻) и «Источники» (настройки); режимы «Моё / Преподаватели / Вместе»; неделя со стрелками и полоса дней; на экране один день. Действия над парой («скрыть…») — лист по нажатию на пару. Выбор группы, преподаватели, файл .ics, ручные ссылки, совпадения и «Скрытое» — в листе «Источники».

  • Отмена вместо подтверждений: скрытие, удаление, закрытие долга сразу выполняются, а уведомление внизу предлагает «Отменить». Подтверждение осталось только у удаления долга (два нажатия).

  • Движение: отклик на касание мгновенный; листы двигаются за пальцем, продолжают бросок с той же скоростью и прерываются в любой момент; всё на пружинах без отскока, кроме возврата после броска. При включённом «уменьшении движения» остаётся плавное проявление, при «уменьшении прозрачности» размытие заменяется сплошным фоном, при повышенной контрастности добавляются рамки.

  • Стиль «Liquid Glass» (iOS 26): плавающие стеклянные панели (вкладки-капсула, шапка с неделей, листы, кнопки, уведомления): размытие и насыщение фона, светлая кромка-блик, мягкая тень; под ними мягкие цветные пятна. Это приближение средствами CSS (backdrop-filter), настоящего «линзового» преломления в вебе нет.

  • Адаптация: телефон — нижняя капсула и листы снизу; планшет (от 640 px) — шире колонка; ноутбук и монитор (от 960 px) — боковая панель слева, сводка долгов — полоса сверху, разделы и пары дня в две–три колонки (по ширине), листы окном по центру, подсветка при наведении мыши, закрытие листа клавишей Esc.

  • Чёлка и Dynamic Island: отступы экрана вынесены в токены --sat/--sab/--sal/--sar (из env(safe-area-inset-*)). Заголовки, листы, панель вкладок и боковая панель отступают от вырезов; под чёлкой содержимое при прокрутке уходит в размытие. В Telegram эти же токены можно подменить значениями из Mini App API.

  • Смена недели и дня: название недели, полоса дней и список пар «заезжают» с той стороны, куда листаешь (вперёд — справа, назад — слева); при первом открытии анимации нет, при «уменьшении движения» она мгновенная.

  • Резервная копия: кнопка «⋯» на экране «Долги». Долги, заметки, расписание и настройки сохраняются в файл debts-backup-ДАТА.json (или копируются текстом). Загрузка сначала проверяет файл целиком (повреждённый не загружается вообще), показывает, что внутри, и только по кнопке заменяет данные; прежние данные откладываются, и их можно вернуть там же.

  • Поиск, много результатов: если сайт показывает результаты по страницам, под списком появляется «Показать ещё». Следующая страница грузится только по нажатию (по одному запросу к сайту), одинаковые строки не дублируются. Сайт листает параметром page (по 20 результатов на странице, номера страниц лежат в блоке pagination); приложение берёт это из ссылок самой страницы (parsePager в domain/directory.ts); если сайт отдал не всё и листания нет, под списком пишется «Показано N из M» (число берётся со страницы). Разбор проверен на настоящих страницах asu.ru (преподаватели «Иванов», группы «2.409»), они лежат в __fixtures__/; имя параметра и номер проверяются, прежде чем уйти на сайт.

  • Серверные функции (api/) на Vercel: проект собран как ES-модули, поэтому в файлах api/*.ts импорты из src/ пишутся с суффиксом .js (../src/domain/directory.js), иначе Node на Vercel не находит модуль и функция отвечает 500. Каждая функция обёрнута так, чтобы неожиданный сбой возвращался как JSON с причиной.

  • Сервер в России (Yandex Cloud Functions): сайт asu.ru не отвечает на запросы с зарубежных адресов, в том числе с серверов Vercel (UND_ERR_CONNECT_TIMEOUT, регион не помогает). Поэтому те же три функции (search, directory, ics) запускаются в Yandex Cloud. Файл для функции собирается командой npm run build:yandex (результат — yandex/function.js, его вставляют в редактор функции). Адрес функции задаётся в файле .env.production одной строкой VITE_API_BASE=https://functions.yandexcloud.net/<ID функции>; без неё приложение ходит на свои /api/… (так работает npm run dev). Код обращения — src/data/api.ts.

    Защита функции. Адрес функции открыт всем, поэтому она отвечает только Mini App: приложение шлёт подписанные данные Telegram (initData) в заголовке x-tg-init-data, функция проверяет подпись токеном бота и пускает только перечисленные Telegram-ID. Ещё один человек не может делать больше 30 запросов в минуту. Нужно задать две переменные окружения функции в Yandex Cloud (Редактор → Переменные окружения): TELEGRAM_BOT_TOKEN (тот же токен бота, что в Supabase) и ALLOWED_TG_IDS (твой Telegram-ID, несколько через запятую). Если они не заданы, функция не отвечает (500), а не пускает всех. Из обычного браузера расписание через эту функцию не загрузится: подписи там нет. Локально (npm run dev) заголовок не нужен.

  • Регион серверных функций: vercel.json ставит функциям регион fra1 (Франкфурт) и лимит 30 с на запрос: сайт asu.ru может не отвечать на запросы из американского региона Vercel по умолчанию (UND_ERR_CONNECT_TIMEOUT).

  • Заметки: третья вкладка. Заголовок необязателен (берётся первая строка), сохраняется само, пустую заметку не храним, удаление отменяется кнопкой в уведомлении. Хранится в localStorage (notes-v1).

  • Сводка расписания (ноутбук и монитор): под панелью недели — «Ближайшая пара» (идёт сейчас или через сколько начнётся), «Сдать долги» (для каждого незакрытого долга с преподавателем — когда и где его ближайшая очная пара; преподаватель сопоставляется с загруженным расписанием по фамилии) и столбики загрузки недели; по клику переходит на нужный день. Считается в domain/insights.ts. Расписание преподавателя можно найти прямо из окна долга: кнопка «Найти расписание преподавателя» открывает поиск с уже подставленной фамилией (кнопка пропадает, когда расписание найдено).

  • Тема: светлая и тёмная следуют настройке устройства; токены цвета, отступов и шрифтов собраны в начале src/index.css.

Модель долгов (src/domain/debts.ts)

Долг — предмет, преподаватель (необязательно), требования текстом, заметки, флаг «закрыт без работ» и список работ. У работы есть название (по умолчанию «Работа 1…N») и отметка «сдана»; промежуточных состояний нет.

Статус вычисляется, а не хранится:

Условие Статус
закрыт вручную Закрыт
нет работ Уточнить требования
есть несданные работы В работе
все работы сданы Закрыт

Прогресс: основной показатель — «закрыто предметов N из M»; рядом «работ сдано N из M» (долги без требований в него не входят и подписываются отдельно). У каждого долга своя полоса.

Модель расписания (src/domain/schedule.ts, teachers.ts)

  • Три режима: «Моё», «Преподаватели», «Вместе».
  • День студента — от начала первой до конца последней очной непрятаной пары. Дистанционные пары показываются для информации, в границы дня и подбор не входят.
  • Скрытие, три уровня: предмет целиком; «пара» (повторяется каждую неделю, ключ — предмет + преподаватель + день недели + время); отдельное занятие. Всё можно вернуть на экране «Скрытые». Подгруппы не разбираются: из параллельных пар (одно время) выбирается своя кнопкой «Это моя пара», остальные скрываются по ключу.
  • Подбор пар преподавателя к моему дню (teacherDay): последняя пара «до», все «внутри» (пометка «совпадает с твоей парой» или «в окне твоего дня»), первая «после». Пары, которые уже ведёт у моей группы тот же преподаватель, не дублируются. Дистанционные пары преподавателя скрыты, внизу пишется «скрыто N пар без аудитории». В день без моих очных пар показывается простой список очных пар преподавателя.
  • Совпадения по времени можно показывать или скрывать: общая настройка и переопределение для каждого преподавателя.
  • Защита от путаницы: при загрузке приложение определяет, чьё это расписание (по названию календаря и числу преподавателей), и не даёт вставить расписание преподавателя вместо группы и наоборот.
  • Дорога между корпусами не считается, но корпус показывается (последняя буква номера аудитории).

Данные пользователя

Пока всё лежит в localStorage браузера: долги, расписание группы, скрытия, преподаватели, источники для обновления и кэш списков. Это временное решение до подключения Supabase, при очистке данных браузера всё пропадёт.

Что дальше

  1. Supabase (PostgreSQL): таблицы долгов и работ, личные настройки (скрытия, выбранные источники); RLS, чтобы личные данные пользователя были отделены от общих расписаний.
  2. Telegram Mini App: открывать внутри Telegram; initData проверять на сервере (подпись), не доверять данным с клиента.
  3. Vercel: хостинг сайта и функций api/*; перед выкладкой проверить лимиты бесплатных тарифов Vercel и Supabase.
  4. Позже: уведомления, чек-листы, ручные консультации.

Известные риски

  • Сайт расписания может изменить вёрстку или ограничить запросы. Разбор страниц построен по адресам ссылок и устойчив к вёрстке, но не к смене структуры адресов; при проблемах остаётся загрузка .ics файлом.
  • В .ics отмена пары видна только по исчезновению события, а пустая неделя неотличима от ошибки загрузки: поведение на пустой неделе не проверено.
  • Признак «дистант = нет номера аудитории» проверен на нескольких расписаниях; для расписаний всех преподавателей данных пока мало.
  • Ссылка .ics строится из адреса страницы; если сайт изменит правило имени файла, пригодится ручная вставка ссылки («Вставить ссылку на .ics вручную»).

Telegram Mini App

  • В index.html подключён официальный telegram-web-app.js. В обычном браузере он ничего не меняет: src/platform/telegram.ts включается, только если у страницы есть initData.
  • Внутри Telegram: ready(), expand(), на телефоне requestFullscreen() (Bot API 8.0; отступы под кнопки Telegram — html.tg в index.css), отключён свайп вниз (иначе листы закрывали бы приложение), цвета шапки и фона Telegram берутся из --bg.
  • Кнопка «Назад» Telegram закрывает верхний открытый лист: стек в src/platform/backStack.ts, листы регистрируются сами.
  • Подключение: в @BotFather → /newapp (или /setmenubutton) → адрес сайта на Vercel.

Синхронизация (Supabase)

  • Приложение по-прежнему работает с localStorage; src/data/syncEngine.ts замечает записи по ключам из копии, отправляет их в таблицу user_data (supabase/schema.sql) и забирает чужие изменения (при старте, при возврате на вкладку, раз в 30 с не чаще).
  • Решения «отправить / забрать / отложить конфликт» — в src/domain/sync.ts (с тестами). Если данные есть и здесь, и на сервере, а устройство ещё не синхронизировалось, побеждает сервер, а локальная версия откладывается (вкладка «Настройки» → «Сохранить замещённые данные»).
  • Вход: supabase/functions/telegram-auth (адресное имя telegram-auth; если панель создала функцию под другим именем, задай его в VITE_AUTH_FUNCTION) проверяет подпись initData токеном бота и выдаёт сессию. Секреты: TELEGRAM_BOT_TOKEN, ALLOWED_TG_IDS. «Verify JWT» у функции выключен.
  • Адрес и публичный ключ — в .env.production (VITE_SUPABASE_URL, VITE_SUPABASE_KEY). Без них синхронизация выключена.
  • Вход из обычного браузера пока не сделан: там приложение работает с локальными данными.

Уведомления от бота

Бот пишет в Telegram про преподавателей, которым нужно сдать долги (берутся незакрытые долги, у которых указан преподаватель; он находится среди отслеживаемых по фамилии):

  • утром: у кого из них сегодня очные пары, где и как это стоит относительно твоих пар (окно, до, после);
  • вечером: то же на завтра;
  • перед парой такого преподавателя, если у тебя в это время нет своей пары.

Настройки — вкладка «Настройки» → «Уведомления». Если ни у кого из нужных преподавателей в этот день нет очных пар, сообщение не приходит.

Как устроено: планировщик Supabase (pg_cron) раз в 5 минут вызывает функцию notify. Она читает данные пользователей из user_data, решает, что пора отправить, шлёт через Bot API и записывает отправленное в notify_sent, чтобы не повторяться. Сайт asu.ru функция не трогает: расписание берётся то, что приложение последний раз сохранило (обновил расписание в приложении — оно синхронизировалось — уведомления используют его).

Настройка (один раз):

  1. SQL Editor: выполнить supabase/schema.sql заново (он безопасно повторяется: добавляет ключ notify-v1 и таблицу notify_sent).
  2. npm run build:notify, затем создать функцию notify в Supabase и вставить в неё supabase/functions/notify/function.js. Verify JWT выключить.
  3. Секреты функции: TELEGRAM_BOT_TOKEN (уже есть) и новый CRON_SECRET (любая длинная случайная строка).
  4. В supabase/notify.sql подставить ID проекта и тот же CRON_SECRET, выполнить в SQL Editor.
  5. Открыть чат с ботом и нажать «Запустить»; в приложении нажать «Отправить пробное сообщение».

About

a personal project for study

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages