Личное приложение студента АлтГУ: учёт академических долгов и расписание своей группы вместе с расписанием преподавателей, у которых можно сдавать долги. Сделано как 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.
Долг — предмет, преподаватель (необязательно), требования текстом, заметки, флаг «закрыт без работ» и список работ. У работы есть название (по умолчанию «Работа 1…N») и отметка «сдана»; промежуточных состояний нет.
Статус вычисляется, а не хранится:
| Условие | Статус |
|---|---|
| закрыт вручную | Закрыт |
| нет работ | Уточнить требования |
| есть несданные работы | В работе |
| все работы сданы | Закрыт |
Прогресс: основной показатель — «закрыто предметов N из M»; рядом «работ сдано N из M» (долги без требований в него не входят и подписываются отдельно). У каждого долга своя полоса.
- Три режима: «Моё», «Преподаватели», «Вместе».
- День студента — от начала первой до конца последней очной непрятаной пары. Дистанционные пары показываются для информации, в границы дня и подбор не входят.
- Скрытие, три уровня: предмет целиком; «пара» (повторяется каждую неделю, ключ — предмет + преподаватель + день недели + время); отдельное занятие. Всё можно вернуть на экране «Скрытые». Подгруппы не разбираются: из параллельных пар (одно время) выбирается своя кнопкой «Это моя пара», остальные скрываются по ключу.
- Подбор пар преподавателя к моему дню (
teacherDay): последняя пара «до», все «внутри» (пометка «совпадает с твоей парой» или «в окне твоего дня»), первая «после». Пары, которые уже ведёт у моей группы тот же преподаватель, не дублируются. Дистанционные пары преподавателя скрыты, внизу пишется «скрыто N пар без аудитории». В день без моих очных пар показывается простой список очных пар преподавателя. - Совпадения по времени можно показывать или скрывать: общая настройка и переопределение для каждого преподавателя.
- Защита от путаницы: при загрузке приложение определяет, чьё это расписание (по названию календаря и числу преподавателей), и не даёт вставить расписание преподавателя вместо группы и наоборот.
- Дорога между корпусами не считается, но корпус показывается (последняя буква номера аудитории).
Пока всё лежит в localStorage браузера: долги, расписание группы, скрытия, преподаватели, источники для обновления и кэш списков. Это временное решение до подключения Supabase, при очистке данных браузера всё пропадёт.
- Supabase (PostgreSQL): таблицы долгов и работ, личные настройки (скрытия, выбранные источники); RLS, чтобы личные данные пользователя были отделены от общих расписаний.
- Telegram Mini App: открывать внутри Telegram;
initDataпроверять на сервере (подпись), не доверять данным с клиента. - Vercel: хостинг сайта и функций
api/*; перед выкладкой проверить лимиты бесплатных тарифов Vercel и Supabase. - Позже: уведомления, чек-листы, ручные консультации.
- Сайт расписания может изменить вёрстку или ограничить запросы. Разбор страниц построен по адресам ссылок и устойчив к вёрстке, но не к смене структуры адресов; при проблемах остаётся загрузка
.icsфайлом. - В
.icsотмена пары видна только по исчезновению события, а пустая неделя неотличима от ошибки загрузки: поведение на пустой неделе не проверено. - Признак «дистант = нет номера аудитории» проверен на нескольких расписаниях; для расписаний всех преподавателей данных пока мало.
- Ссылка
.icsстроится из адреса страницы; если сайт изменит правило имени файла, пригодится ручная вставка ссылки («Вставить ссылку на .ics вручную»).
- В
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.
- Приложение по-прежнему работает с 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 функция не трогает: расписание берётся то, что приложение последний раз сохранило (обновил расписание в приложении — оно синхронизировалось — уведомления используют его).
Настройка (один раз):
- SQL Editor: выполнить
supabase/schema.sqlзаново (он безопасно повторяется: добавляет ключnotify-v1и таблицуnotify_sent). npm run build:notify, затем создать функциюnotifyв Supabase и вставить в неёsupabase/functions/notify/function.js. Verify JWT выключить.- Секреты функции:
TELEGRAM_BOT_TOKEN(уже есть) и новыйCRON_SECRET(любая длинная случайная строка). - В
supabase/notify.sqlподставить ID проекта и тот жеCRON_SECRET, выполнить в SQL Editor. - Открыть чат с ботом и нажать «Запустить»; в приложении нажать «Отправить пробное сообщение».