1
0
Fork 0
mirror of https://github.com/EDeev/tablo.git synced 2026-10-07 20:49:31 +03:00

Full Project

This commit is contained in:
Деев Егор Викторович 2026-05-19 13:49:09 +03:00
commit 3946aeb81e
65 changed files with 7399 additions and 0 deletions

17
.env.example Normal file
View file

@ -0,0 +1,17 @@
# ── OpenAI API ──────────────────────────────────────────────────
OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_MODEL=
# ── PostgreSQL ─────────────────────────────────────────────────
DB_HOST=
DB_PORT=
DB_NAME=
DB_USER=
DB_PASSWORD=
# ── Переменные окружения ───────────────────────────────────────
PYTHONDONTWRITEBYTECODE=1

84
README.md Normal file
View file

@ -0,0 +1,84 @@
# Tablo
Веб-приложение для работы с учебным расписанием: распознавание расписания из изображения с помощью ИИ, персональные трекеры успеваемости и совместный доступ по ссылке.
Сайт: [tablo.deev.su](https://tablo.deev.su)
## Функциональность
- Загрузка фотографии или скана расписания и автоматическое извлечение структурированных данных через AI Vision API
- Полный CRUD для расписаний, предметов и слотов занятий
- 11 типов виджетов трекеров успеваемости (посещаемость, оценки, дедлайны, серии дней и др.)
- Дашборд с перетаскиваемыми виджетами на сетке 12 колонок
- Совместный доступ по токен-ссылке с тремя режимами: просмотр, редактирование, клонирование как шаблон
- Объединение нескольких расписаний с разрешением конфликтов по предметам
## Технологический стек
- **Backend:** Python, Flask, SQLAlchemy, Flask-Migrate, Flask-Login
- **База данных:** PostgreSQL (JSONB для хранения структуры расписания)
- **ИИ:** Gemini 3.1 Flash через OpenAI-совместимый API (Timeweb Cloud AI)
- **Frontend:** Jinja2, Bootstrap 5.3, Vanilla JS, Fetch API
## Установка
**Требования:** Python 3.11+, PostgreSQL
```bash
git clone https://github.com/EDeev/tablo.git
cd tablo
python -m venv venv
venv\Scripts\activate # Windows
# source venv/bin/activate # Linux/macOS
pip install -r requirements.txt
```
Создайте файл `.env` в корне проекта:
```env
SECRET_KEY=your_secret_key
DATABASE_URL=postgresql://user:password@localhost:5432/tablo
OPENAI_API_KEY=your_api_key
OPENAI_BASE_URL=https://api.your-provider.com/v1
```
Примените миграции и запустите приложение:
```bash
flask db upgrade
python run.py
```
Приложение будет доступно по адресу `http://localhost:5000`.
## Структура проекта
```
tablo/
├── app/
│ ├── models/ # Модели SQLAlchemy
│ ├── routes/ # Блюпринты (auth, schedules, subjects, shares)
│ ├── services/ # Бизнес-логика (AI-распознавание, AI-генерация метрик)
│ └── templates/ # HTML-шаблоны Jinja2
├── migrations/ # Файлы миграций Flask-Migrate
├── requirements.txt
└── run.py
```
## Лицензия
Этот проект является некоммерческим и распространяется под лицензией MIT.
## Автор
**Деев Егор Викторович** - Backend Developer
- GitHub: [@EDeev](https://github.com/EDeev)
- Email: egor@deev.space
- Telegram: [@Egor_Deev](https://t.me/Egor_Deev)
---
<div align="center">
<sub>⭐ Если проект оказался полезным, поставьте звездочку на GitHub!</sub>
<p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p>
</div>

31
app/__init__.py Normal file
View file

@ -0,0 +1,31 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
csrf = CSRFProtect()
def create_app():
app = Flask(__name__)
app.config.from_object('app.config.Config')
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
csrf.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Войдите для доступа'
login_manager.login_message_category = 'warning'
from app.routes import auth, schedules, subjects, shares
app.register_blueprint(auth.bp)
app.register_blueprint(schedules.bp)
app.register_blueprint(subjects.bp)
app.register_blueprint(shares.bp)
return app

14
app/config.py Normal file
View file

@ -0,0 +1,14 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.getenv('SECRET_KEY', 'change-me-in-production')
SQLALCHEMY_DATABASE_URI = (
f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}"
f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAX_CONTENT_LENGTH = 20 * 1024 * 1024
WTF_CSRF_ENABLED = True

4
app/models/__init__.py Normal file
View file

@ -0,0 +1,4 @@
from app.models.user import User
from app.models.schedule import Schedule, SubjectConfig
from app.models.metric import Metric
from app.models.share import Share, ShareEditor

37
app/models/metric.py Normal file
View file

@ -0,0 +1,37 @@
from app import db
METRIC_TYPES = [
'checkpoints', 'progress_bar', 'counter', 'stages',
'checklist', 'attendance', 'grades', 'deadlines',
'streak', 'effort_hours', 'rating_history',
]
METRIC_LABELS = {
'checkpoints': 'Контрольные точки',
'progress_bar': 'Накопление баллов',
'counter': 'Счётчик',
'stages': 'Этапы',
'checklist': 'Чеклист',
'attendance': 'Посещаемость',
'grades': 'Оценки',
'deadlines': 'Дедлайны',
'streak': 'Серия',
'effort_hours': 'Трудозатраты',
'rating_history': 'История оценок',
}
class Metric(db.Model):
__tablename__ = 'metrics'
id = db.Column(db.Integer, primary_key=True)
subject_config_id = db.Column(db.Integer, db.ForeignKey('subject_configs.id'), nullable=False)
type = db.Column(db.String(32), nullable=False)
label = db.Column(db.String(256), nullable=False)
config = db.Column(db.JSON, nullable=False, default=dict)
progress = db.Column(db.JSON, nullable=False, default=dict)
grid_col = db.Column(db.Integer, nullable=False, default=0)
grid_row = db.Column(db.Integer, nullable=False, default=0)
grid_w = db.Column(db.Integer, nullable=False, default=1)
grid_h = db.Column(db.Integer, nullable=False, default=1)
ai_prompt = db.Column(db.Text, nullable=True)

40
app/models/schedule.py Normal file
View file

@ -0,0 +1,40 @@
from app import db
from datetime import datetime
class Schedule(db.Model):
__tablename__ = 'schedules'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
name = db.Column(db.String(128), nullable=False)
data = db.Column(db.JSON, nullable=False, default=list)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
subject_configs = db.relationship('SubjectConfig', backref='schedule',
lazy='dynamic', cascade='all, delete-orphan')
shares = db.relationship('Share', backref='schedule', lazy='dynamic',
cascade='all, delete-orphan')
COMPLETION_TYPES = ['Зачёт', 'Диф. зачёт', 'Экзамен']
class SubjectConfig(db.Model):
__tablename__ = 'subject_configs'
id = db.Column(db.Integer, primary_key=True)
schedule_id = db.Column(db.Integer, db.ForeignKey('schedules.id'), nullable=False)
subject_name = db.Column(db.String(256), nullable=False)
description = db.Column(db.Text, nullable=True)
hidden = db.Column(db.Boolean, nullable=False, default=False)
completion_type = db.Column(db.String(32), nullable=True)
completed = db.Column(db.Boolean, nullable=False, default=False)
sdo_url = db.Column(db.Text, nullable=True)
metrics = db.relationship('Metric', backref='subject_config',
order_by='Metric.grid_row, Metric.grid_col, Metric.id',
cascade='all, delete-orphan')
__table_args__ = (
db.UniqueConstraint('schedule_id', 'subject_name', name='uq_schedule_subject'),
)

30
app/models/share.py Normal file
View file

@ -0,0 +1,30 @@
from app import db
from datetime import datetime
import uuid
class Share(db.Model):
__tablename__ = 'shares'
id = db.Column(db.Integer, primary_key=True)
schedule_id = db.Column(db.Integer, db.ForeignKey('schedules.id'), nullable=False)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# view = только просмотр, edit = совместное редактирование, template = клонирование
share_type = db.Column(db.String(16), nullable=False, default='view')
token = db.Column(db.String(36), unique=True, nullable=False,
default=lambda: str(uuid.uuid4()))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
editors = db.relationship('ShareEditor', backref='share', lazy='dynamic',
cascade='all, delete-orphan')
class ShareEditor(db.Model):
__tablename__ = 'share_editors'
id = db.Column(db.Integer, primary_key=True)
share_id = db.Column(db.Integer, db.ForeignKey('shares.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
__table_args__ = (
db.UniqueConstraint('share_id', 'user_id', name='uq_share_editor'),
)

28
app/models/user.py Normal file
View file

@ -0,0 +1,28 @@
from app import db, login_manager
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
login = db.Column(db.String(64), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(256), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
schedules = db.relationship('Schedule', backref='owner', lazy='dynamic',
cascade='all, delete-orphan')
shares = db.relationship('Share', backref='owner', lazy='dynamic',
foreign_keys='Share.owner_id')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

0
app/routes/__init__.py Normal file
View file

55
app/routes/auth.py Normal file
View file

@ -0,0 +1,55 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.models.user import User
bp = Blueprint('auth', __name__)
@bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('schedules.profile'))
if request.method == 'POST':
login = request.form.get('login', '').strip()
password = request.form.get('password', '')
confirm = request.form.get('confirm', '')
if not login or not password:
flash('Заполните все поля', 'danger')
elif len(login) < 3:
flash('Логин не менее 3 символов', 'danger')
elif password != confirm:
flash('Пароли не совпадают', 'danger')
elif User.query.filter_by(login=login).first():
flash('Логин уже занят', 'danger')
else:
user = User(login=login)
user.set_password(password)
db.session.add(user)
db.session.commit()
login_user(user)
return redirect(url_for('schedules.profile'))
return render_template('auth/register.html')
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('schedules.profile'))
if request.method == 'POST':
login = request.form.get('login', '').strip()
password = request.form.get('password', '')
user = User.query.filter_by(login=login).first()
if user and user.check_password(password):
login_user(user, remember=bool(request.form.get('remember')))
next_page = request.args.get('next')
return redirect(next_page or url_for('schedules.profile'))
flash('Неверный логин или пароль', 'danger')
return render_template('auth/login.html')
@bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('auth.login'))

471
app/routes/schedules.py Normal file
View file

@ -0,0 +1,471 @@
import copy
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, jsonify)
from flask_login import login_required, current_user
from app import db, csrf
from app.models.schedule import Schedule, SubjectConfig
from app.models.metric import Metric
from app.services.ai_scan import scan_image
from app.services.merge import merge_schedules_data
from app.services.schedule_helpers import (
build_week_view, get_week_dates, DAYS_DISPLAY, DAYS_RU
)
from datetime import date
bp = Blueprint('schedules', __name__)
@bp.route('/')
@login_required
def profile():
schedules = current_user.schedules.order_by(Schedule.created_at.desc()).all()
return render_template('profile/index.html', schedules=schedules)
@bp.route('/schedules/upload', methods=['GET', 'POST'])
@login_required
def upload():
if request.method == 'POST':
name = request.form.get('name', '').strip()
extra_prompt = request.form.get('extra_prompt', '').strip()
file = request.files.get('image')
if not name:
flash('Введите название расписания', 'danger')
return render_template('schedule/upload.html')
if not file or file.filename == '':
flash('Выберите картинку', 'danger')
return render_template('schedule/upload.html')
ext = '.' + file.filename.rsplit('.', 1)[-1].lower()
allowed = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
if ext not in allowed:
flash('Неподдерживаемый формат изображения', 'danger')
return render_template('schedule/upload.html')
try:
image_bytes = file.read()
data = scan_image(image_bytes, ext, extra_prompt)
except Exception as e:
flash(f'Ошибка сканирования: {e}', 'danger')
return render_template('schedule/upload.html')
schedule = Schedule(user_id=current_user.id, name=name, data=data)
db.session.add(schedule)
db.session.commit()
flash('Расписание успешно создано', 'success')
return redirect(url_for('schedules.view', schedule_id=schedule.id))
return render_template('schedule/upload.html')
@bp.route('/schedules/<int:schedule_id>')
@login_required
def view(schedule_id):
schedule = Schedule.query.get_or_404(schedule_id)
if not _can_view(schedule):
flash('Нет доступа', 'danger')
return redirect(url_for('schedules.profile'))
week_dates = get_week_dates()
week_view = build_week_view(schedule.data or [])
today_idx = date.today().weekday()
configs = {sc.subject_name: sc for sc in schedule.subject_configs}
can_edit = _can_edit(schedule)
# Все трекеры для дэшборда — сортируем по сохранённой позиции в сетке
all_metrics = []
for sc in schedule.subject_configs:
for m in sc.metrics:
all_metrics.append({'subject': sc.subject_name, 'metric': m})
all_metrics.sort(key=lambda e: (e['metric'].grid_row, e['metric'].grid_col, e['metric'].id))
return render_template('schedule/view.html',
schedule=schedule,
week_view=week_view,
week_dates=week_dates,
days_display=DAYS_DISPLAY,
days_ru=DAYS_RU,
today_idx=today_idx,
configs=configs,
can_edit=can_edit,
is_shared_view=False,
all_metrics=all_metrics)
@bp.route('/schedules/<int:schedule_id>/delete', methods=['POST'])
@login_required
def delete(schedule_id):
schedule = Schedule.query.get_or_404(schedule_id)
if schedule.user_id != current_user.id:
flash('Нет доступа', 'danger')
return redirect(url_for('schedules.profile'))
db.session.delete(schedule)
db.session.commit()
flash('Расписание удалено', 'success')
return redirect(url_for('schedules.profile'))
@bp.route('/schedules/merge', methods=['POST'])
@login_required
def merge():
ids = request.form.getlist('schedule_ids')
name = request.form.get('name', '').strip()
if len(ids) < 2:
flash('Выберите хотя бы 2 расписания', 'danger')
return redirect(url_for('schedules.profile'))
if not name:
flash('Введите название', 'danger')
return redirect(url_for('schedules.profile'))
schedules = Schedule.query.filter(
Schedule.id.in_(ids),
Schedule.user_id == current_user.id
).all()
if len(schedules) < 2:
flash('Расписания не найдены', 'danger')
return redirect(url_for('schedules.profile'))
merged_data = merge_schedules_data([s.data for s in schedules])
merged = Schedule(user_id=current_user.id, name=name, data=merged_data)
db.session.add(merged)
db.session.commit()
flash(f'Расписание "{name}" создано', 'success')
return redirect(url_for('schedules.view', schedule_id=merged.id))
# ─── Переименование расписания ────────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/rename', methods=['POST'])
@csrf.exempt
@login_required
def rename_schedule(schedule_id):
schedule = Schedule.query.get_or_404(schedule_id)
if not _can_edit(schedule):
return jsonify({'error': 'Нет доступа'}), 403
name = request.json.get('name', '').strip()
if not name:
return jsonify({'error': 'Пустое имя'}), 400
schedule.name = name
db.session.commit()
return jsonify({'ok': True})
# ─── Глобальное переименование поля предмета ─────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/field',
methods=['POST'])
@csrf.exempt
@login_required
def rename_subject_field(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _can_edit(schedule):
return jsonify({'error': 'Нет доступа'}), 403
body = request.json or {}
field = body.get('field') # name | classroom | teacher | type
new_value = body.get('value', '').strip()
old_type = body.get('old_type', '') # только для field=type
data = copy.deepcopy(schedule.data or [])
for item in data:
if item.get('subject') != subject_name:
continue
if field == 'name':
item['subject'] = new_value
elif field == 'classroom':
_replace_slot_field(item, idx=2, value=new_value or None)
elif field == 'teacher':
_replace_slot_field(item, idx=3, value=new_value or None)
elif field == 'type' and old_type:
types = item.get('types', {})
if old_type in types:
types[new_value] = types.pop(old_type)
schedule.data = data
if field == 'name':
cfg = SubjectConfig.query.filter_by(
schedule_id=schedule_id, subject_name=subject_name).first()
if cfg:
cfg.subject_name = new_value
db.session.commit()
return jsonify({'ok': True})
def _replace_slot_field(item: dict, idx: int, value):
"""Меняет поле слота по индексу во всех типах и датах"""
for tn, type_data in item.get('types', {}).items():
date_ranges = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
for dr, slots in date_ranges.items():
for slot in slots:
while len(slot) <= idx:
slot.append(None)
slot[idx] = value
# ─── Изменение времени конкретного слота ─────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/slot-time',
methods=['POST'])
@csrf.exempt
@login_required
def edit_slot_time(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _can_edit(schedule):
return jsonify({'error': 'Нет доступа'}), 403
body = request.json or {}
day = (body.get('day') or '').lower()
old_time = body.get('old_time', '')
new_time = body.get('new_time', '').strip()
type_name = body.get('type_name', '')
dr_key = body.get('date_range', '')
data = copy.deepcopy(schedule.data or [])
for item in data:
if item.get('subject') != subject_name:
continue
for tn, type_data in item.get('types', {}).items():
if type_name and tn != type_name:
continue
drs = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
for dr, slots in drs.items():
if dr_key and dr != dr_key:
continue
for slot in slots:
if slot[0].lower() == day and (len(slot) < 2 or slot[1] == old_time):
if len(slot) > 1:
slot[1] = new_time
schedule.data = data
db.session.commit()
return jsonify({'ok': True})
# ─── Полное редактирование одного слота ──────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/slot',
methods=['PATCH'])
@csrf.exempt
@login_required
def edit_slot(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _can_edit(schedule):
return jsonify({'error': 'Нет доступа'}), 403
body = request.json or {}
old_day = (body.get('old_day') or '').lower()
old_time = body.get('old_time', '')
type_name = body.get('type_name', '')
dr_key = body.get('date_range', '')
new_day = (body.get('day') or '').lower()
new_time = body.get('time', '').strip()
new_classroom = body.get('classroom', '').strip() or None
new_teacher = body.get('teacher', '').strip() or None
data = copy.deepcopy(schedule.data or [])
changed = False
for item in data:
if item.get('subject') != subject_name:
continue
for tn, type_data in item.get('types', {}).items():
if type_name and tn != type_name:
continue
drs = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
for dr, slots in drs.items():
if dr_key and dr != dr_key:
continue
for slot in slots:
if slot[0].lower() == old_day and (not old_time or (len(slot) > 1 and slot[1] == old_time)):
while len(slot) < 4:
slot.append(None)
slot[0] = new_day
slot[1] = new_time
slot[2] = new_classroom
slot[3] = new_teacher
changed = True
if changed:
schedule.data = data
db.session.commit()
return jsonify({'ok': True})
# ─── Добавление нового слота ──────────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/slot',
methods=['PUT'])
@csrf.exempt
@login_required
def add_slot(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _can_edit(schedule):
return jsonify({'error': 'Нет доступа'}), 403
body = request.json or {}
type_name = body.get('type_name', '').strip()
dr_key = body.get('date_range', '').strip()
day = (body.get('day') or '').lower().strip()
time_val = body.get('time', '').strip()
classroom = body.get('classroom', '').strip() or None
teacher = body.get('teacher', '').strip() or None
if not type_name or not dr_key or not day:
return jsonify({'error': 'Укажите тип, период и день'}), 400
data = copy.deepcopy(schedule.data or [])
for item in data:
if item.get('subject') != subject_name:
continue
types = item.setdefault('types', {})
if type_name not in types:
types[type_name] = {'color': 'gray', 'dates': {}}
type_data = types[type_name]
if isinstance(type_data, dict) and 'dates' in type_data:
drs = type_data['dates']
else:
drs = type_data
slot_list = drs.setdefault(dr_key, [])
slot_list.append([day, time_val, classroom, teacher])
schedule.data = data
db.session.commit()
return jsonify({'ok': True})
return jsonify({'error': 'Предмет не найден'}), 404
# ─── Переименование периода (date_range ключа) ────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/period',
methods=['POST'])
@csrf.exempt
@login_required
def rename_period(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _can_edit(schedule):
return jsonify({'error': 'Нет доступа'}), 403
body = request.json or {}
type_name = body.get('type_name', '')
old_dr = body.get('old_period', '').strip()
new_dr = body.get('new_period', '').strip()
if not old_dr or not new_dr:
return jsonify({'error': 'Укажите старый и новый период'}), 400
data = copy.deepcopy(schedule.data or [])
for item in data:
if item.get('subject') != subject_name:
continue
for tn, type_data in item.get('types', {}).items():
if type_name and tn != type_name:
continue
drs = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
if old_dr in drs:
drs[new_dr] = drs.pop(old_dr)
schedule.data = data
db.session.commit()
return jsonify({'ok': True})
# ─── Удаление предмета полностью ─────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>',
methods=['DELETE'])
@csrf.exempt
@login_required
def delete_subject(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _can_edit(schedule):
return jsonify({'error': 'Нет доступа'}), 403
schedule.data = [item for item in (schedule.data or [])
if item.get('subject') != subject_name]
cfg = SubjectConfig.query.filter_by(
schedule_id=schedule_id, subject_name=subject_name).first()
if cfg:
db.session.delete(cfg)
db.session.commit()
return jsonify({'ok': True})
# ─── Удаление конкретного слота ───────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/slot',
methods=['DELETE'])
@csrf.exempt
@login_required
def delete_slot(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _can_edit(schedule):
return jsonify({'error': 'Нет доступа'}), 403
body = request.json or {}
day = (body.get('day') or '').lower()
time_val = body.get('time', '')
type_name = body.get('type_name', '')
dr_key = body.get('date_range', '')
data = copy.deepcopy(schedule.data or [])
for item in data:
if item.get('subject') != subject_name:
continue
for tn in list(item.get('types', {}).keys()):
if type_name and tn != type_name:
continue
type_data = item['types'][tn]
new_format = isinstance(type_data, dict) and 'dates' in type_data
drs = type_data['dates'] if new_format else type_data
for dr in list(drs.keys()):
if dr_key and dr != dr_key:
continue
drs[dr] = [
s for s in drs[dr]
if not (s[0].lower() == day and
(len(s) < 2 or s[1] == time_val))
]
if not drs[dr]:
del drs[dr]
if not drs:
del item['types'][tn]
# Убираем предметы без слотов
schedule.data = [item for item in data if item.get('types')]
db.session.commit()
return jsonify({'ok': True})
# ─── Вспомогательные функции доступа ─────────────────────────────────────────
def _can_view(schedule: Schedule) -> bool:
if schedule.user_id == current_user.id:
return True
from app.models.share import ShareEditor, Share
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
if edit_share:
editor = ShareEditor.query.filter_by(
share_id=edit_share.id, user_id=current_user.id).first()
if editor:
return True
return False
def _can_edit(schedule: Schedule) -> bool:
if schedule.user_id == current_user.id:
return True
from app.models.share import ShareEditor, Share
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
if edit_share:
editor = ShareEditor.query.filter_by(
share_id=edit_share.id, user_id=current_user.id).first()
if editor:
return True
return False

111
app/routes/shares.py Normal file
View file

@ -0,0 +1,111 @@
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, jsonify)
from flask_login import login_required, current_user
from app import db, csrf
from app.models.schedule import Schedule, SubjectConfig
from app.models.share import Share, ShareEditor
from app.models.user import User
from app.services.schedule_helpers import build_week_view, get_week_dates, DAYS_DISPLAY, DAYS_RU
from app.services.merge import merge_schedules_data
from datetime import date
bp = Blueprint('shares', __name__)
@bp.route('/schedules/<int:schedule_id>/share', methods=['POST'])
@csrf.exempt
@login_required
def create_share(schedule_id):
schedule = Schedule.query.get_or_404(schedule_id)
if schedule.user_id != current_user.id:
return jsonify({'error': 'Нет доступа'}), 403
share_type = request.json.get('type', 'view')
if share_type not in ('view', 'edit', 'template'):
return jsonify({'error': 'Неизвестный тип'}), 400
# Один тип = одна ссылка на расписание
existing = Share.query.filter_by(
schedule_id=schedule_id, share_type=share_type).first()
if existing:
return jsonify({'ok': True, 'token': existing.token})
share = Share(schedule_id=schedule_id, owner_id=current_user.id,
share_type=share_type)
db.session.add(share)
db.session.commit()
return jsonify({'ok': True, 'token': share.token})
@bp.route('/schedules/<int:schedule_id>/share/editors', methods=['POST'])
@csrf.exempt
@login_required
def add_editor(schedule_id):
schedule = Schedule.query.get_or_404(schedule_id)
if schedule.user_id != current_user.id:
return jsonify({'error': 'Нет доступа'}), 403
login = request.json.get('login', '').strip()
user = User.query.filter_by(login=login).first()
if not user:
return jsonify({'error': 'Пользователь не найден'}), 404
edit_share = Share.query.filter_by(
schedule_id=schedule_id, share_type='edit').first()
if not edit_share:
edit_share = Share(schedule_id=schedule_id, owner_id=current_user.id,
share_type='edit')
db.session.add(edit_share)
db.session.flush()
existing = ShareEditor.query.filter_by(
share_id=edit_share.id, user_id=user.id).first()
if not existing:
db.session.add(ShareEditor(share_id=edit_share.id, user_id=user.id))
db.session.commit()
return jsonify({'ok': True, 'token': edit_share.token})
@bp.route('/shared/<token>')
def view_shared(token):
share = Share.query.filter_by(token=token).first_or_404()
schedule = share.schedule
if share.share_type == 'template':
# Клонирование — только для авторизованных
from flask_login import current_user
if not current_user.is_authenticated:
flash('Войдите чтобы использовать шаблон', 'warning')
return redirect(url_for('auth.login'))
cloned = Schedule(
user_id=current_user.id,
name=f'{schedule.name} (копия)',
data=schedule.data,
)
db.session.add(cloned)
db.session.commit()
flash('Расписание добавлено в ваш профиль', 'success')
return redirect(url_for('schedules.view', schedule_id=cloned.id))
week_dates = get_week_dates()
week_view = build_week_view(schedule.data or [])
today_idx = date.today().weekday()
configs = {sc.subject_name: sc for sc in schedule.subject_configs}
all_metrics = []
for sc in schedule.subject_configs:
for m in sc.metrics:
all_metrics.append({'subject': sc.subject_name, 'metric': m})
all_metrics.sort(key=lambda e: (e['metric'].grid_row, e['metric'].grid_col, e['metric'].id))
return render_template('schedule/view.html',
schedule=schedule,
week_view=week_view,
week_dates=week_dates,
days_display=DAYS_DISPLAY,
days_ru=DAYS_RU,
today_idx=today_idx,
configs=configs,
can_edit=False,
is_shared_view=True,
all_metrics=all_metrics)

301
app/routes/subjects.py Normal file
View file

@ -0,0 +1,301 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from sqlalchemy.orm.attributes import flag_modified
from app import db, csrf
from app.models.schedule import Schedule, SubjectConfig, COMPLETION_TYPES
from app.models.metric import Metric, METRIC_TYPES, METRIC_LABELS
from app.services.ai_metrics import generate_metric_from_prompt
bp = Blueprint('subjects', __name__)
# ─── Страница предметов ───────────────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects')
@login_required
def subjects_page(schedule_id):
schedule = Schedule.query.get_or_404(schedule_id)
can_edit = _check_edit_access(schedule)
configs = {sc.subject_name: sc for sc in schedule.subject_configs}
return render_template('schedule/subjects.html',
schedule=schedule,
configs=configs,
can_edit=can_edit,
completion_types=COMPLETION_TYPES)
# ─── Описание предмета ────────────────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/description',
methods=['POST'])
@csrf.exempt
@login_required
def update_description(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _check_edit_access(schedule):
return jsonify({'error': 'Нет доступа'}), 403
config = _get_or_create_config(schedule_id, subject_name)
config.description = request.json.get('description', '')
db.session.commit()
return jsonify({'ok': True})
# ─── Добавить метрику (вручную) ───────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/metrics',
methods=['POST'])
@csrf.exempt
@login_required
def add_metric(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _check_edit_access(schedule):
return jsonify({'error': 'Нет доступа'}), 403
body = request.json or {}
metric_type = body.get('type', '')
label = body.get('label', '').strip()
config = body.get('config', {})
if metric_type not in METRIC_TYPES:
return jsonify({'error': 'Неизвестный тип'}), 400
if not label:
label = METRIC_LABELS.get(metric_type, 'Метрика')
cfg = _get_or_create_config(schedule_id, subject_name)
metric = Metric(subject_config_id=cfg.id, type=metric_type,
label=label, config=config, progress={})
db.session.add(metric)
db.session.commit()
return jsonify({'ok': True, 'html': _render_metric_html(metric)})
# ─── Добавить метрику через AI ────────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/metrics/ai',
methods=['POST'])
@csrf.exempt
@login_required
def add_metric_ai(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _check_edit_access(schedule):
return jsonify({'error': 'Нет доступа'}), 403
body = request.json or {}
prompt = body.get('prompt', '').strip()
if not prompt:
return jsonify({'error': 'Пустой промт'}), 400
try:
result = generate_metric_from_prompt(subject_name, prompt)
except Exception as e:
return jsonify({'error': str(e)}), 500
cfg = _get_or_create_config(schedule_id, subject_name)
metric = Metric(subject_config_id=cfg.id,
type=result.get('type', 'checkpoints'),
label=result.get('label', 'Метрика'),
config=result.get('config', {}),
progress={},
ai_prompt=prompt)
db.session.add(metric)
db.session.commit()
return jsonify({'ok': True, 'html': _render_metric_html(metric)})
# ─── Сохранить прогресс метрики ───────────────────────────────────────────────
# Принимает весь объект progress целиком и перезаписывает его в БД.
@bp.route('/api/metrics/<int:metric_id>/progress', methods=['POST'])
@csrf.exempt
@login_required
def save_progress(metric_id):
metric = Metric.query.get_or_404(metric_id)
if not _check_edit_access(metric.subject_config.schedule):
return jsonify({'error': 'Нет доступа'}), 403
metric.progress = request.json or {}
flag_modified(metric, 'progress')
db.session.commit()
return jsonify({'ok': True})
# ─── Сохранить позицию/размер метрики в сетке ────────────────────────────────
@bp.route('/api/metrics/<int:metric_id>/layout', methods=['POST'])
@csrf.exempt
@login_required
def save_layout(metric_id):
metric = Metric.query.get_or_404(metric_id)
if not _check_edit_access(metric.subject_config.schedule):
return jsonify({'error': 'Нет доступа'}), 403
body = request.json or {}
for field in ('grid_col', 'grid_row', 'grid_w', 'grid_h'):
if field in body:
setattr(metric, field, int(body[field]))
db.session.commit()
return jsonify({'ok': True})
# ─── Переименовать метрику ────────────────────────────────────────────────────
@bp.route('/api/metrics/<int:metric_id>/rename', methods=['POST'])
@csrf.exempt
@login_required
def rename_metric(metric_id):
metric = Metric.query.get_or_404(metric_id)
if not _check_edit_access(metric.subject_config.schedule):
return jsonify({'error': 'Нет доступа'}), 403
label = (request.json or {}).get('label', '').strip()
if not label:
return jsonify({'error': 'Пустое название'}), 400
metric.label = label
db.session.commit()
return jsonify({'ok': True})
# ─── Скрыть / показать предмет в расписании ──────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/visibility',
methods=['POST'])
@csrf.exempt
@login_required
def toggle_subject_visibility(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _check_edit_access(schedule):
return jsonify({'error': 'Нет доступа'}), 403
cfg = _get_or_create_config(schedule_id, subject_name)
cfg.hidden = not cfg.hidden
db.session.commit()
return jsonify({'ok': True, 'hidden': cfg.hidden})
# ─── Тип сдачи предмета ──────────────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/completion_type',
methods=['POST'])
@csrf.exempt
@login_required
def set_completion_type(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _check_edit_access(schedule):
return jsonify({'error': 'Нет доступа'}), 403
value = (request.json or {}).get('completion_type')
if value and value not in COMPLETION_TYPES:
return jsonify({'error': 'Неверный тип'}), 400
cfg = _get_or_create_config(schedule_id, subject_name)
cfg.completion_type = value or None
db.session.commit()
return jsonify({'ok': True, 'completion_type': cfg.completion_type})
# ─── Завершить / возобновить предмет ─────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/completed',
methods=['POST'])
@csrf.exempt
@login_required
def toggle_completed(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _check_edit_access(schedule):
return jsonify({'error': 'Нет доступа'}), 403
cfg = _get_or_create_config(schedule_id, subject_name)
cfg.completed = not cfg.completed
db.session.commit()
return jsonify({'ok': True, 'completed': cfg.completed})
# ─── Ссылка на СДО ───────────────────────────────────────────────────────────
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/sdo_url',
methods=['POST'])
@csrf.exempt
@login_required
def set_sdo_url(schedule_id, subject_name):
schedule = Schedule.query.get_or_404(schedule_id)
if not _check_edit_access(schedule):
return jsonify({'error': 'Нет доступа'}), 403
url = (request.json or {}).get('sdo_url', '').strip()
cfg = _get_or_create_config(schedule_id, subject_name)
cfg.sdo_url = url or None
db.session.commit()
return jsonify({'ok': True})
# ─── Получить промт AI-трекера ────────────────────────────────────────────────
@bp.route('/api/metrics/<int:metric_id>/ai_prompt', methods=['GET'])
@login_required
def get_metric_ai_prompt(metric_id):
metric = Metric.query.get_or_404(metric_id)
if not _check_edit_access(metric.subject_config.schedule):
return jsonify({'error': 'Нет доступа'}), 403
return jsonify({'ok': True, 'prompt': metric.ai_prompt or ''})
# ─── Сохранить порядок/размеры метрик батчем ─────────────────────────────────
@bp.route('/api/metrics/layout_batch', methods=['POST'])
@csrf.exempt
@login_required
def save_layout_batch():
items = (request.json or {}).get('items', [])
if not items:
return jsonify({'ok': True})
ids = [int(item['id']) for item in items]
metrics_map = {m.id: m for m in Metric.query.filter(Metric.id.in_(ids)).all()}
for item in items:
metric = metrics_map.get(int(item['id']))
if not metric:
continue
if not _check_edit_access(metric.subject_config.schedule):
return jsonify({'error': 'Нет доступа'}), 403
for field in ('grid_col', 'grid_row', 'grid_w', 'grid_h'):
if field in item:
setattr(metric, field, int(item[field]))
db.session.commit()
return jsonify({'ok': True})
# ─── Удалить метрику ──────────────────────────────────────────────────────────
@bp.route('/api/metrics/<int:metric_id>', methods=['DELETE'])
@csrf.exempt
@login_required
def delete_metric(metric_id):
metric = Metric.query.get_or_404(metric_id)
if not _check_edit_access(metric.subject_config.schedule):
return jsonify({'error': 'Нет доступа'}), 403
db.session.delete(metric)
db.session.commit()
return jsonify({'ok': True})
# ─── Вспомогательные функции ──────────────────────────────────────────────────
def _get_or_create_config(schedule_id: int, subject_name: str) -> SubjectConfig:
config = SubjectConfig.query.filter_by(
schedule_id=schedule_id, subject_name=subject_name).first()
if not config:
config = SubjectConfig(schedule_id=schedule_id, subject_name=subject_name)
db.session.add(config)
db.session.flush()
return config
def _check_edit_access(schedule: Schedule) -> bool:
if schedule.user_id == current_user.id:
return True
from app.models.share import Share, ShareEditor
share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
if share:
return ShareEditor.query.filter_by(
share_id=share.id, user_id=current_user.id).first() is not None
return False
def _render_metric_html(metric: Metric) -> str:
from flask import render_template_string
return render_template_string(
'{% from "macros/metrics.html" import render_metric %}{{ render_metric(metric, True) }}',
metric=metric
)

0
app/services/__init__.py Normal file
View file

View file

@ -0,0 +1,80 @@
import json
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
# Схема всех типов метрик для AI
METRICS_SCHEMA_PROMPT = """Доступные типы метрик:
1. checkpoints — N независимых точек для отметки (лабы, задания, тесты)
config: { "total": число, "labels": ["Лаба 1", ...] или null }
2. progress_bar — Накопление баллов/очков до максимума
config: { "max": число, "unit": "баллов", "step": шаг_или_null }
3. counter — Простой счётчик до цели
config: { "max": число, "step": 1, "unit": "единица" }
4. stages — Последовательные этапы работы
config: { "stages": ["Тема", "План", "Черновик", "Защита"] }
5. checklist — Именованный список задач
config: { "items": ["Задача 1", "Задача 2", ...] }
6. attendance — Отметки посещаемости (был/не был/уважительная)
config: { "total_classes": число_или_null }
7. grades — Оценки за работы с весами и средним
config: { "works": [{ "name": "Работа 1", "weight": 1 }], "max_grade": 5 }
8. deadlines — Задачи с датами дедлайнов
config: { "items": [{ "name": "Задача", "date": "ДД.ММ" }] }
9. streak — Серия непрерывных дней активности
config: {}
10. effort_hours — Учёт трудозатрат в часах
config: { "goal_hours": число_или_null }
11. rating_history — История оценок в динамике
config: { "scale": 5 }
"""
def generate_metric_from_prompt(subject_name: str, user_prompt: str) -> dict:
client = OpenAI(
api_key=os.getenv('OPENAI_API_KEY'),
base_url=os.getenv('OPENAI_BASE_URL') or None,
)
model = os.getenv('OPENAI_MODEL', 'gpt-4o')
system = (
'Ты — помощник по созданию метрик успеваемости. '
'Выбери подходящий тип метрики по описанию пользователя и верни ТОЛЬКО JSON без лишнего текста.\n\n'
+ METRICS_SCHEMA_PROMPT
+ '\nОтвет строго в формате: { "type": "тип", "label": "короткое название", "config": {...} }'
)
user_msg = (
f'Предмет: {subject_name}\n'
f'Описание от пользователя: {user_prompt}'
)
response = client.chat.completions.create(
model=model,
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user_msg},
],
temperature=0,
)
raw = response.choices[0].message.content.strip()
if raw.startswith('```'):
lines = raw.splitlines()
end = len(lines) - 1 if lines[-1].strip() == '```' else len(lines)
raw = '\n'.join(lines[1:end])
return json.loads(raw)

134
app/services/ai_scan.py Normal file
View file

@ -0,0 +1,134 @@
import base64
import json
import os
import tempfile
from pathlib import Path
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
TYPE_COLOR_POOL = """
Доступные цвета для типов занятий (выбери наиболее подходящий для каждого типа):
"blue" — лекции, теоретические занятия
"green" — практики, семинары
"yellow" — лабораторные работы, практикумы
"purple" — семинары, коллоквиумы
"orange" — консультации, факультативы
"red" — экзамены, зачёты, контрольные
"teal" — проектная работа, курсовые
"gray" — прочее, не указано
"""
SYSTEM_PROMPT = """Ты — точный парсер расписания учебных занятий.
Тебе дадут картинку с расписанием. Твоя задача — извлечь ВСЕ занятия и вернуть ТОЛЬКО валидный JSON, без лишнего текста, пояснений и markdown.
═══════════════════════════════════════════════
ВАЖНО: ОДИН ПРЕДМЕТ — ОДИН ОБЪЕКТ В МАССИВЕ
═══════════════════════════════════════════════
Один и тот же предмет может встречаться в расписании много раз:
— в разные дни недели
— в разное время
— с разными типами (Лекция, Практика, Лаб. работа)
— в разные периоды дат
— в разных аудиториях
— у разных преподавателей
ВСЕ вхождения одного предмета должны быть объединены в ОДИН объект!
═══════════════════════════════════════════════
СТРУКТУРА ОТВЕТА
═══════════════════════════════════════════════
[
{
"subject": "Название предмета",
"types": {
"Тип занятия": {
"color": "blue",
"dates": {
"ДД.ММ-ДД.ММ": [
["день", "ЧЧ:ММ-ЧЧ:ММ", "кабинет_или_null", "преподаватель_или_null"]
]
}
}
}
}
]
═══════════════════════════════════════════════
ПРАВИЛА
═══════════════════════════════════════════════
subject — обязательно, точное название
types — обязательно, словарь типов
Ключ типа — "Лекция", "Практика", "Лаб. работа" и т.п.
Если тип не указан — "Не указано"
color — цвет типа из пула ниже, выбери по смыслу типа
""" + TYPE_COLOR_POOL + """
Ключ дат — формат "ДД.ММ-ДД.ММ", например "02.02-05.04"
Месяцы: янв=01 фев=02 мар=03 апр=04 май=05 июн=06
июл=07 авг=08 сен=09 окт=10 ноя=11 дек=12
Если период не указан — "Весь период"
Строка слота:
[0] день — нижний регистр (понедельник, вторник...)
[1] время — "ЧЧ:ММ-ЧЧ:ММ"
[2] кабинет — строка или null
[3] преподаватель — строка (несколько через запятую) или null
ВАЖНО: поле types изменилось — теперь каждый тип содержит объект с полями "color" и "dates" (вместо прямого словаря дат).
"""
def _encode_image(image_bytes: bytes, ext: str) -> tuple[str, str]:
mime_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.webp': 'image/webp', '.bmp': 'image/bmp'}
mime = mime_map.get(ext.lower(), 'image/jpeg')
return base64.standard_b64encode(image_bytes).decode('utf-8'), mime
def _strip_markdown(raw: str) -> str:
raw = raw.strip()
if raw.startswith('```'):
lines = raw.splitlines()
end = len(lines) - 1 if lines[-1].strip() == '```' else len(lines)
raw = '\n'.join(lines[1:end])
return raw
def scan_image(image_bytes: bytes, ext: str, extra_prompt: str = '') -> list[dict]:
client = OpenAI(
api_key=os.getenv('OPENAI_API_KEY'),
base_url=os.getenv('OPENAI_BASE_URL') or None,
)
model = os.getenv('OPENAI_MODEL', 'gpt-4o')
image_data, mime_type = _encode_image(image_bytes, ext)
user_text = 'Извлеки расписание из этого изображения и верни JSON строго по инструкции.'
if extra_prompt:
user_text += f'\n\nДополнительные указания от пользователя: {extra_prompt}'
response = client.chat.completions.create(
model=model,
messages=[
{'role': 'system', 'content': SYSTEM_PROMPT},
{
'role': 'user',
'content': [
{
'type': 'image_url',
'image_url': {
'url': f'data:{mime_type};base64,{image_data}',
'detail': 'high',
},
},
{'type': 'text', 'text': user_text},
],
},
],
temperature=0,
)
raw = response.choices[0].message.content
return json.loads(_strip_markdown(raw))

23
app/services/merge.py Normal file
View file

@ -0,0 +1,23 @@
def merge_schedules_data(schedules_data: list[list]) -> list[dict]:
"""Объединяет данные нескольких расписаний"""
subjects: dict[str, dict] = {}
for schedule in schedules_data:
for item in schedule:
name = item.get('subject', '').strip()
if not name:
continue
entry = subjects.setdefault(name, {'subject': name, 'types': {}})
for type_name, type_data in item.get('types', {}).items():
new_fmt = isinstance(type_data, dict) and 'dates' in type_data
color = type_data.get('color', 'gray') if new_fmt else 'gray'
date_ranges = type_data['dates'] if new_fmt else type_data
typ = entry['types'].setdefault(type_name, {'color': color, 'dates': {}})
typ_dates = typ['dates']
for dr, slots in date_ranges.items():
slot_list = typ_dates.setdefault(dr, [])
for slot in slots:
if slot not in slot_list:
slot_list.append(slot)
return list(subjects.values())

View file

@ -0,0 +1,119 @@
from datetime import date, timedelta
from collections import defaultdict
DAYS_RU = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']
DAYS_DISPLAY = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']
def _parse_date_range(dr: str):
"""Парсим 'ДД.ММ-ДД.ММ' в даты"""
if not dr or dr == 'Весь период':
return None, None
try:
start_str, end_str = dr[:5], dr[6:]
year = date.today().year
start = date(year, int(start_str[3:5]), int(start_str[0:2]))
end = date(year, int(end_str[3:5]), int(end_str[0:2]))
return start, end
except Exception:
return None, None
def get_week_dates():
"""Даты текущей недели Пн-Вс"""
today = date.today()
monday = today - timedelta(days=today.weekday())
return [monday + timedelta(days=i) for i in range(7)]
def _merge_same_subject(slots: list) -> dict:
"""Несколько слотов одного предмета в одно время — объединяем различающиеся поля через /"""
merged = dict(slots[0])
for field in ('classroom', 'teacher', 'type'):
vals = list(dict.fromkeys(str(s.get(field) or '') for s in slots))
vals = [v for v in vals if v]
merged[field] = ' / '.join(vals) if vals else None
return merged
def _group_by_time(slots: list) -> list:
"""Группирует слоты по времени; один предмет — мёрджит поля; разные предметы — группа"""
by_time = defaultdict(list)
for slot in slots:
by_time[slot.get('time') or ''].append(slot)
result = []
for time_key in sorted(by_time.keys()):
group = by_time[time_key]
subjects = {s['subject'] for s in group}
if len(subjects) == 1:
result.append(_merge_same_subject(group))
else:
result.append({'_group': True, 'time': time_key, 'slots': group})
return result
def build_day_view(schedule_data: list, target_date: date) -> list:
"""Занятия на конкретную дату с группировкой по времени"""
day_name = DAYS_RU[target_date.weekday()]
raw = []
for item in schedule_data:
subject = item.get('subject', '')
for type_name, type_data in item.get('types', {}).items():
# Support both old format (dates directly) and new format ({color, dates})
if isinstance(type_data, dict) and 'dates' in type_data:
type_color = type_data.get('color', 'gray')
date_ranges = type_data['dates']
else:
type_color = 'gray'
date_ranges = type_data
for dr, slots in date_ranges.items():
start, end = _parse_date_range(dr)
if start and end and not (start <= target_date <= end):
continue
for slot in slots:
if not slot or slot[0].lower() != day_name:
continue
raw.append({
'subject': subject,
'type': type_name,
'type_name': type_name,
'color': type_color,
'time': slot[1] if len(slot) > 1 else None,
'classroom': slot[2] if len(slot) > 2 else None,
'teacher': slot[3] if len(slot) > 3 else None,
'date_range': dr,
'day': day_name,
})
raw.sort(key=lambda x: x.get('time') or '')
return _group_by_time(raw)
def build_week_view(schedule_data: list) -> dict:
"""Расписание на всю неделю"""
week_dates = get_week_dates()
return {i: build_day_view(schedule_data, d) for i, d in enumerate(week_dates)}
def is_now(time_str: str) -> bool:
"""Идёт ли занятие прямо сейчас"""
if not time_str or '-' not in time_str:
return False
from datetime import datetime
try:
parts = time_str.split('-')
now = datetime.now()
start = datetime.strptime(parts[0].strip(), '%H:%M').replace(
year=now.year, month=now.month, day=now.day)
end = datetime.strptime(parts[1].strip(), '%H:%M').replace(
year=now.year, month=now.month, day=now.day)
return start <= now <= end
except Exception:
return False
def get_all_subjects(schedule_data: list) -> list:
"""Список уникальных названий предметов"""
return [item.get('subject', '') for item in schedule_data if item.get('subject')]

128
app/static/css/base.css Normal file
View file

@ -0,0 +1,128 @@
/* ═══ CSS-переменные ════════════════════════════════════════════════════════ */
:root,
[data-theme="light"] {
--green: #3D7A5F;
--mint: #E8F5EE;
--bg: #F9F9F8;
--surface: #FFFFFF;
--surface2: #F4F4F3;
--ink: #1A1A1A;
--muted: #6B7280;
--border: #E5E7EB;
--mono: 'JetBrains Mono', monospace;
color-scheme: light;
}
[data-theme="dark"] {
--green: #4A9B77;
--mint: #1A2E25;
--bg: #111110;
--surface: #1C1C1B;
--surface2: #242423;
--ink: #EDEDED;
--muted: #71717A;
--border: #2E2E2C;
--mono: 'JetBrains Mono', monospace;
color-scheme: dark;
}
/* ═══ Сброс ══════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--ink);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: stretch;
}
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
/* ═══ Страница ════════════════════════════════════════════════════════════════ */
.t-page { max-width: 1080px; width: 100%; margin: 0 auto; padding: 28px 24px; flex: 1; }
/* ═══ Заголовок раздела ═══════════════════════════════════════════════════════ */
.t-section-title {
font-size: 15px;
font-weight: 700;
letter-spacing: -.01em;
margin-bottom: 14px;
margin-top: 32px;
}
.t-section-title-inline { margin: 0; }
/* ═══ Шапка страницы (PH) ════════════════════════════════════════════════════ */
.t-ph { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; flex-wrap: wrap; }
.t-ph-title { font-size: 19px; font-weight: 700; letter-spacing: -.02em; }
.t-ph-week { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.t-ph-right { margin-left: auto; display: flex; gap: 8px; }
.t-ph-name-row { display: flex; align-items: center; gap: 8px; }
/* ═══ Утилиты ════════════════════════════════════════════════════════════════ */
.min-w-0 { min-width: 0; }
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.text-muted { color: var(--muted); }
.text-green { color: var(--green); }
.t-link { color: var(--green); text-decoration: none; }
.t-link:hover { text-decoration: underline; }
.mono { font-family: var(--mono); }
.fw-bold { font-weight: 700; }
.fw-semi { font-weight: 600; }
.fs-small { font-size: 11.5px; }
.hidden { display: none !important; }
.t-muted { color: var(--muted); font-size: 13px; margin-left: 6px; }
/* Layout-помощники */
.row-center { display: flex; align-items: center; }
.row-gap-8 { display: flex; align-items: center; gap: 8px; }
.row-gap-6 { display: flex; align-items: center; gap: 6px; }
.row-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
.col-gap-6 { display: flex; flex-direction: column; gap: 6px; }
.ml-auto { margin-left: auto; }
/* ═══ Empty state ════════════════════════════════════════════════════════════ */
.t-empty {
text-align: center;
padding: 64px 0;
color: var(--muted);
}
.t-empty-icon { font-size: 40px; margin-bottom: 14px; }
.t-empty p { font-size: 13px; margin-bottom: 14px; }
/* ═══ Разделитель ════════════════════════════════════════════════════════════ */
.t-divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
/* ═══ Футер ══════════════════════════════════════════════════════════════════ */
.t-footer {
max-width: 1080px;
width: 100%;
margin: 40px auto 24px;
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.t-footer-sep { opacity: .4; }
.t-footer-link { color: var(--muted); text-decoration: none; }
.t-footer-link:hover { color: var(--ink); }
.t-footer-brand {
font-weight: 800;
font-size: 13px;
letter-spacing: -.04em;
color: var(--ink);
display: flex;
align-items: center;
line-height: 1;
text-decoration: none;
}
.t-footer-brand .t-brand-lo {
font-size: 12px;
padding: 1px 4px 2px;
}

View file

@ -0,0 +1,258 @@
/* ═══ Кнопки ══════════════════════════════════════════════════════════════════ */
.t-btn {
border: none;
border-radius: 8px;
padding: 6px 14px;
font-size: 12.5px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
transition: opacity .15s, background .15s;
white-space: nowrap;
}
.t-btn-primary { background: var(--green); color: #fff; }
.t-btn-primary:hover { opacity: .88; }
.t-btn-outline {
background: none;
border: 1px solid var(--border);
color: var(--ink);
}
.t-btn-outline:hover { border-color: var(--muted); background: var(--surface2); }
.t-btn-ghost { background: none; border: none; color: var(--muted); padding: 6px 10px; }
.t-btn-ghost:hover { color: var(--ink); background: var(--surface2); }
.t-btn-danger { background: none; border: 1px solid var(--border); color: #f87171; }
[data-theme="light"] .t-btn-danger { border-color: #fecaca; color: #b91c1c; }
.t-btn-danger:hover { background: var(--surface2); border-color: #f87171; }
[data-theme="light"] .t-btn-danger:hover { background: #fee2e2; border-color: #fecaca; }
/* Размеры */
.t-btn-sm { font-size: 12px; padding: 4px 10px; }
.t-btn-xs { font-size: 11px; padding: 3px 8px; }
.t-btn-full { width: 100%; justify-content: center; }
.t-btn-group-end { border-radius: 0 8px 8px 0; white-space: nowrap; }
/* Кнопка-иконка */
.t-btn-icon {
background: none;
border: none;
padding: 3px 6px;
border-radius: 5px;
cursor: pointer;
display: inline-flex;
align-items: center;
color: var(--muted);
transition: background .12s, color .12s;
}
.t-btn-icon:hover { background: var(--surface2); color: var(--ink); }
.t-btn-icon.danger:hover { background: var(--surface2); color: #f87171; }
[data-theme="light"] .t-btn-icon.danger:hover { background: #fee2e2; color: #b91c1c; }
.t-btn-icon-ml { margin-left: auto; }
/* Кнопка режима редактирования */
.t-edit-btn {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 12px;
font-size: 12px;
font-family: inherit;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all .15s;
}
.t-edit-btn.on { border-color: var(--green); color: var(--green); background: var(--mint); }
/* ═══ Алерты ══════════════════════════════════════════════════════════════════ */
.t-alert {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
border: 1px solid transparent;
}
.t-alert-close { background: none; border: none; font-size: 16px; color: inherit; opacity: .6; margin-left: auto; cursor: pointer; line-height: 1; }
.t-alert-success { background: var(--mint); border-color: var(--green); color: #6ee7a0; }
[data-theme="light"] .t-alert-success { color: #166534; border-color: #a7f3d0; }
.t-alert-danger, .t-alert-error { background: #3b0f0f; border-color: #7f1d1d; color: #f87171; }
[data-theme="light"] .t-alert-danger, [data-theme="light"] .t-alert-error { background: #fee2e2; border-color: #fecaca; color: #b91c1c; }
.t-alert-warning { background: #332600; border-color: #78350f; color: #f5d06a; }
[data-theme="light"] .t-alert-warning { background: #fef9c3; border-color: #fde68a; color: #854d0e; }
.t-alert-info { background: #1e3a5f; border-color: #1e40af; color: #93c5fd; }
[data-theme="light"] .t-alert-info { background: #dbeafe; border-color: #bfdbfe; color: #1d4ed8; }
/* ═══ Бейджи типов ════════════════════════════════════════════════════════════ */
.t-type {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.t-type-blue { background: #dbeafe; color: #1d4ed8; }
.t-type-green { background: #dcfce7; color: #166534; }
.t-type-yellow { background: #fef9c3; color: #854d0e; }
.t-type-purple { background: #ede9fe; color: #5b21b6; }
.t-type-orange { background: #ffedd5; color: #9a3412; }
.t-type-red { background: #fee2e2; color: #b91c1c; }
.t-type-teal { background: #ccfbf1; color: #0f766e; }
.t-type-gray { background: #f1f5f9; color: #475569; }
[data-theme="dark"] .t-type-blue { background: #1e3a5f; color: #93c5fd; }
[data-theme="dark"] .t-type-green { background: #14352a; color: #6ee7b7; }
[data-theme="dark"] .t-type-yellow { background: #3a2e0a; color: #fde68a; }
[data-theme="dark"] .t-type-purple { background: #2d1f5e; color: #c4b5fd; }
[data-theme="dark"] .t-type-orange { background: #3d1f0a; color: #fdba74; }
[data-theme="dark"] .t-type-red { background: #3d1212; color: #fca5a5; }
[data-theme="dark"] .t-type-teal { background: #0a2e2a; color: #5eead4; }
[data-theme="dark"] .t-type-gray { background: #1e2530; color: #94a3b8; }
/* Бейдж «Сегодня» */
.t-badge-today {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 20px;
background: var(--mint);
color: var(--green);
}
/* ═══ Поля ввода ══════════════════════════════════════════════════════════════ */
.t-input {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
color: var(--ink);
background: var(--surface);
outline: none;
transition: border-color .15s;
}
.t-input:focus { border-color: var(--green); }
.t-input-sm { padding: 5px 10px; font-size: 12px; border-radius: 6px; }
.t-input-group { display: flex; gap: 0; }
.t-input-group .t-input { border-radius: 8px 0 0 8px; }
.t-input-group .t-btn { border-radius: 0 8px 8px 0; border-left: none; }
.t-textarea { resize: vertical; min-height: 72px; }
.t-select {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
color: var(--ink);
background: var(--surface);
outline: none;
cursor: pointer;
}
.t-select:focus { border-color: var(--green); }
.t-select-sm { padding: 5px 10px; font-size: 12px; border-radius: 6px; }
.t-label { display: block; font-size: 12px; font-weight: 600; color: var(--ink); margin-bottom: 5px; }
.t-field { margin-bottom: 14px; }
.t-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
.t-form-check { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.t-form-check input[type=checkbox] { accent-color: var(--green); width: 14px; height: 14px; cursor: pointer; }
/* ═══ Модальные окна ══════════════════════════════════════════════════════════ */
.t-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.35);
z-index: 300;
align-items: center;
justify-content: center;
}
.t-modal-backdrop.open { display: flex; }
.t-modal {
background: var(--surface);
border-radius: 12px;
width: 420px;
max-width: calc(100vw - 32px);
box-shadow: 0 8px 40px rgba(0,0,0,.18);
overflow: hidden;
}
.t-modal-sm { width: 300px; }
.t-modal-header {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.t-modal-title { font-size: 14px; font-weight: 700; }
.t-modal-body { padding: 16px; }
.t-modal-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.t-modal-close-btn { margin-left: auto; }
/* ═══ Спиннер ════════════════════════════════════════════════════════════════ */
.t-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--border);
border-top-color: var(--green);
border-radius: 50%;
animation: spin .7s linear infinite;
display: inline-block;
will-change: transform;
}
@keyframes spin { to { rotate: 360deg; } }
/* ═══ Режим редактирования ════════════════════════════════════════════════════ */
.edit-ctrl { display: none !important; }
body.edit-mode .edit-ctrl { display: flex !important; }
body.edit-mode button.edit-ctrl { display: inline-flex !important; }
body.edit-mode a.edit-ctrl { display: inline-flex !important; }
/* ═══ Offcanvas (предметы — legacy, оставлено для обратной совместимости) ══ */
.t-offcanvas-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.3);
z-index: 200;
}
.t-offcanvas-backdrop.open { display: block; }
.t-offcanvas {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: 480px;
max-width: 100vw;
background: var(--surface);
border-left: 1px solid var(--border);
z-index: 201;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform .25s ease;
}
.t-offcanvas.open { transform: translateX(0); }
.t-offcanvas-header {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.t-offcanvas-title { font-size: 15px; font-weight: 700; }
.t-offcanvas-body { flex: 1; overflow-y: auto; padding: 0; }

47
app/static/css/forms.css Normal file
View file

@ -0,0 +1,47 @@
/* ═══ Страница загрузки расписания ══════════════════════════════════════════ */
.t-upload-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
max-width: 560px;
margin: 0 auto;
}
.t-file-drop {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 32px;
text-align: center;
cursor: pointer;
transition: border-color .15s, background .15s;
display: block;
}
.t-file-drop:hover { border-color: var(--green); background: var(--mint); }
.t-file-drop input { display: none; }
.t-upload-drop-text { font-size: 13px; font-weight: 500; margin-top: 10px; margin-bottom: 4px; }
.t-preview-img { max-height: 280px; border-radius: 8px; border: 1px solid var(--border); margin-top: 10px; display: block; }
.t-change-file-btn { margin-top: 6px; }
.t-image-preview { display: none; flex-direction: column; align-items: center; gap: 8px; }
.t-image-preview.visible { display: flex; }
/* ═══ Страница входа / регистрации ══════════════════════════════════════════ */
.t-auth-wrap {
display: flex;
align-items: center;
justify-content: center;
padding-top: 48px;
}
.t-auth-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
width: 380px;
max-width: 100%;
}
.t-auth-title { font-size: 20px; font-weight: 700; letter-spacing: -.02em; text-align: center; margin-bottom: 24px; }
.t-auth-footer { text-align: center; font-size: 12.5px; color: var(--muted); margin-top: 16px; }
.t-auth-footer a { color: var(--green); font-weight: 600; }
/* ═══ Кнопка шаринга ══════════════════════════════════════════════════════════ */
.t-share-copy-btn { border-radius: 0 8px 8px 0; border-left: none; }

View file

@ -0,0 +1 @@
/* Этот файл заменён на app/static/css/metrics/_*.css */

View file

@ -0,0 +1,114 @@
/* ═══════════════════════════════════════════════════════════════════════════
ATTENDANCE — посещаемость
Акцент: #ef4444 (red)
gh-1: три числа горизонтально — присут / пропуск / уваж. Форма скрыта.
gh-2: статы + точки-история + форма.
gh-3/4: всё с воздухом, точки крупнее.
═══════════════════════════════════════════════════════════════════════════ */
/* Три статы */
.t-att-stats {
display: flex;
gap: 5px;
flex-shrink: 0;
}
.t-att-stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 5px 3px;
border-radius: 8px;
background: var(--surface2);
min-height: 44px;
}
.t-att-num {
font-size: 20px;
font-weight: 700;
font-family: var(--mono);
line-height: 1;
}
.t-att-lbl {
font-size: 8.5px;
color: var(--muted);
margin-top: 2px;
text-align: center;
white-space: nowrap;
}
.t-att-stat--present .t-att-num { color: #16a34a; }
.t-att-stat--absent .t-att-num { color: #ef4444; }
.t-att-stat--excused .t-att-num { color: #d97706; }
[data-theme="dark"] .t-att-stat--present .t-att-num { color: #4ade80; }
[data-theme="dark"] .t-att-stat--absent .t-att-num { color: #f87171; }
[data-theme="dark"] .t-att-stat--excused .t-att-num { color: #fbbf24; }
/* Точки-история */
.t-att-dots {
flex: 1;
min-height: 0;
display: flex;
flex-wrap: wrap;
gap: 3px;
align-content: flex-start;
overflow: hidden;
padding: 4px 0;
}
.t-att-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
cursor: default;
}
.t-att-dot--present { background: #16a34a; }
.t-att-dot--absent { background: #ef4444; }
.t-att-dot--excused { background: #d97706; }
[data-theme="dark"] .t-att-dot--present { background: #4ade80; }
[data-theme="dark"] .t-att-dot--absent { background: #f87171; }
[data-theme="dark"] .t-att-dot--excused { background: #fbbf24; }
/* Форма */
.t-att-form {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.t-att-form .t-input,
.t-att-form .t-select { flex: 1; min-width: 0; }
/* ── gh-1: только статы ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 8px 8px; }
.t-att-stats { flex: 1; gap: 6px; }
.t-att-stat { flex-direction: row; justify-content: center; gap: 6px; min-height: unset; padding: 6px 8px; }
.t-att-num { font-size: 22px; }
.t-att-lbl { font-size: 9px; margin-top: 0; }
.t-att-dots { display: none; }
.t-att-form { display: none; }
}
/* ── gh-2 ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-att-stat { min-height: 38px; }
.t-att-num { font-size: 18px; }
.t-att-dot { width: 9px; height: 9px; }
.t-att-form .t-input,
.t-att-form .t-select { font-size: 11px; }
}
/* ── gh-3/4 ── */
@container metric (min-height: 360px) {
.t-att-stat { min-height: 60px; border-radius: 10px; }
.t-att-num { font-size: 28px; }
.t-att-lbl { font-size: 10px; }
.t-att-dot { width: 13px; height: 13px; }
.t-att-dots { gap: 4px; }
}
/* ── gw-2/3: статы крупнее ── */
@container metric (min-width: 280px) {
.t-att-num { font-size: 24px; }
.t-att-dot { width: 11px; height: 11px; }
}

View file

@ -0,0 +1,227 @@
/* ═══════════════════════════════════════════════════════════════════════════
METRICS BASE — общая обёртка, grid-контейнер, drag/resize, утилиты
═══════════════════════════════════════════════════════════════════════════ */
/* ── Виджет-обёртка ────────────────────────────────────────────────────────── */
.t-metric {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
position: relative;
container-type: size;
container-name: metric;
transition: box-shadow .15s;
}
/* Цветные акценты — тонкая полоска сверху */
.t-metric--checkpoints { border-top: 3px solid #6366f1; }
.t-metric--progress_bar { border-top: 3px solid #0ea5e9; }
.t-metric--counter { border-top: 3px solid #f59e0b; }
.t-metric--stages { border-top: 3px solid #8b5cf6; }
.t-metric--checklist { border-top: 3px solid #10b981; }
.t-metric--attendance { border-top: 3px solid #ef4444; }
.t-metric--grades { border-top: 3px solid #f97316; }
.t-metric--deadlines { border-top: 3px solid #ec4899; }
.t-metric--streak { border-top: 3px solid #f59e0b; }
.t-metric--effort_hours { border-top: 3px solid #14b8a6; }
.t-metric--rating_history { border-top: 3px solid #6366f1; }
/* ── Заголовок ─────────────────────────────────────────────────────────────── */
.t-metric-header {
display: flex;
align-items: center;
padding: 6px 10px 4px;
flex-shrink: 0;
gap: 6px;
}
.t-metric-title {
font-size: 9.5px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .06em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
/* ── Кнопка переименования в заголовке ────────────────────────────────────── */
.t-metric-rename-btn {
width: 16px;
height: 16px;
padding: 0;
background: none;
border: none;
cursor: pointer;
color: transparent;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
flex-shrink: 0;
transition: color .12s;
}
.t-metric:hover .t-metric-rename-btn { color: var(--muted); }
.t-metric-rename-btn:hover { color: var(--ink) !important; }
/* Скрыть кнопку переименования когда активен режим перетаскивания (там появляется крестик удаления) */
.t-metrics-grid.drag-mode .t-metric-rename-btn { color: transparent !important; pointer-events: none; }
/* ── Тело ──────────────────────────────────────────────────────────────────── */
.t-metric-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 10px 10px;
overflow: hidden;
}
/* ── Пустое состояние ──────────────────────────────────────────────────────── */
.t-metric-empty {
font-size: 11px;
color: var(--muted);
font-style: italic;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
/* ── Крестик удаления — только в drag-mode ────────────────────────────────── */
.t-metric .delete-metric-btn {
display: none;
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
width: 18px;
height: 18px;
padding: 0;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
align-items: center;
justify-content: center;
color: var(--muted);
transition: all .1s;
}
.t-metric .delete-metric-btn:hover { background: #fef2f2; border-color: #ef4444; color: #ef4444; }
[data-theme="dark"] .t-metric .delete-metric-btn:hover { background: rgba(239,68,68,.15); }
.t-metrics-grid.drag-mode .delete-metric-btn { display: flex; }
/* ── Resize-ручка ──────────────────────────────────────────────────────────── */
.t-metric-resize {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: se-resize;
display: none;
align-items: flex-end;
justify-content: flex-end;
padding: 4px;
color: var(--border);
z-index: 5;
transition: color .12s;
}
.t-metric-resize:hover { color: var(--muted); }
.t-metrics-grid.drag-mode .t-metric-resize { display: flex; }
.t-metric-resize svg { pointer-events: none; }
/* ── Drag ──────────────────────────────────────────────────────────────────── */
.t-metrics-grid.drag-mode .t-metric { cursor: grab; }
.t-metrics-grid.drag-mode .t-metric:active { cursor: grabbing; }
.t-metric.drag-ghost {
opacity: .3;
outline: 2px dashed var(--green);
outline-offset: -2px;
}
.t-metric.drag-over {
outline: 2px solid var(--green);
outline-offset: -2px;
background: var(--mint);
}
/* ── Подсветка навигации ───────────────────────────────────────────────────── */
@keyframes metric-highlight {
0%,60% { box-shadow: 0 0 0 3px var(--green); }
100% { box-shadow: 0 0 0 0 transparent; }
}
.t-metric.highlight { animation: metric-highlight 1.8s ease-out forwards; }
/* ═══ Grid-контейнер ══════════════════════════════════════════════════════════ */
.t-metrics-grid {
--mg-cols: 3;
--mg-gap: 12px;
--row-h: 120px;
display: grid;
grid-template-columns: repeat(var(--mg-cols), 1fr);
grid-auto-rows: var(--row-h);
gap: var(--mg-gap);
align-items: stretch;
}
@media (max-width: 900px) { .t-metrics-grid { --mg-cols: 2; } }
@media (max-width: 560px) { .t-metrics-grid { --mg-cols: 1; } }
.t-metric.gw-2 { grid-column: span 2; }
.t-metric.gw-3 { grid-column: span 3; }
.t-metric.gh-1 { grid-row: span 1; }
.t-metric.gh-2 { grid-row: span 2; }
.t-metric.gh-3 { grid-row: span 3; }
.t-metric.gh-4 { grid-row: span 4; }
@media (max-width: 900px) {
.t-metric.gw-2,
.t-metric.gw-3 { grid-column: span 2; }
}
@media (max-width: 560px) {
.t-metric.gw-2,
.t-metric.gw-3 { grid-column: span 1; }
}
/* ── Кнопка "Расположение" ─────────────────────────────────────────────────── */
.t-grid-edit-btn {
font-size: 11.5px;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: none;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all .12s;
font-family: inherit;
white-space: nowrap;
flex-shrink: 0;
}
.t-grid-edit-btn:hover,
.t-grid-edit-btn.active {
border-color: var(--green);
color: var(--green);
background: var(--mint);
}
/* ── Общий мини-бар (переиспользуется в типах) ─────────────────────────────── */
.t-m-bar-track {
background: var(--surface2);
border-radius: 99px;
overflow: hidden;
flex-shrink: 0;
}
.t-m-bar-fill {
height: 100%;
border-radius: 99px;
transition: width .35s cubic-bezier(.4,0,.2,1);
}

View file

@ -0,0 +1,115 @@
/* ═══════════════════════════════════════════════════════════════════════════
CHECKLIST — чеклист
Акцент: #10b981 (emerald)
gh-1: бар прогресса + дробь. Список скрыт.
gh-2+: бар + список со скроллом.
gh-3/4: строки выше, чекбоксы крупнее.
═══════════════════════════════════════════════════════════════════════════ */
.t-cl-bar-row {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-bottom: 6px;
}
.t-cl-track {
flex: 1;
height: 5px;
background: var(--surface2);
border-radius: 99px;
overflow: hidden;
}
.t-cl-fill {
height: 100%;
background: #10b981;
border-radius: 99px;
transition: width .35s cubic-bezier(.4,0,.2,1);
}
.t-cl-frac {
font-size: 11px;
font-weight: 600;
font-family: var(--mono);
color: #10b981;
white-space: nowrap;
flex-shrink: 0;
}
/* Список */
.t-cl-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.t-cl-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 2px;
border-radius: 6px;
transition: background .1s;
flex-shrink: 0;
min-height: 28px;
}
.t-cl-item:hover { background: var(--surface2); }
.t-cl-item input[type=checkbox] { display: none; }
.t-cl-box {
width: 16px;
height: 16px;
border-radius: 5px;
border: 1.5px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all .12s;
color: transparent;
}
.t-cl-item.checked .t-cl-box { background: #10b981; border-color: #10b981; color: #fff; }
.t-cl-text {
font-size: 12px;
color: var(--ink);
line-height: 1.3;
min-width: 0;
flex: 1;
transition: all .12s;
}
.t-cl-item.checked .t-cl-text { color: var(--muted); text-decoration: line-through; }
/* ── gh-1: только бар ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 10px 8px; }
.t-cl-bar-row { flex: 1; align-items: center; margin-bottom: 0; }
.t-cl-track { height: 8px; }
.t-cl-frac { font-size: 16px; }
.t-cl-list { display: none; }
}
/* ── gh-2 ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-cl-item { min-height: 24px; padding: 2px 2px; }
.t-cl-text { font-size: 11.5px; }
.t-cl-box { width: 14px; height: 14px; }
}
/* ── gh-3/4 ── */
@container metric (min-height: 360px) {
.t-cl-item { min-height: 34px; padding: 5px 4px; }
.t-cl-box { width: 18px; height: 18px; border-radius: 6px; }
.t-cl-text { font-size: 13px; }
.t-cl-track { height: 6px; }
}
/* ── gw-2/3: двухколоночный список ── */
@container metric (min-width: 300px) and (min-height: 240px) {
.t-cl-list { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 10px; align-content: start; }
}

View file

@ -0,0 +1,104 @@
/* ═══════════════════════════════════════════════════════════════════════════
CHECKPOINTS — контрольные точки
Акцент: #6366f1 (indigo)
gh-1 (120px): бар + дробь X/N. Точки скрыты.
gh-2 (240px): бар + дробь + кнопки-точки в grid, заполняют пространство.
gh-3/4 (360-480px): то же + подписи под точками видны полностью.
Ширина: gw-1 компактно, gw-2/3 — точки крупнее, в несколько колонок.
═══════════════════════════════════════════════════════════════════════════ */
/* ── Строка прогресса ── */
.t-cp-bar-row {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding-bottom: 6px;
}
.t-cp-fraction {
font-size: 13px;
font-weight: 700;
font-family: var(--mono);
color: #6366f1;
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
}
.t-cp-fraction-max { font-size: 10px; font-weight: 400; color: var(--muted); }
.t-cp-track {
flex: 1;
height: 5px;
background: var(--surface2);
border-radius: 99px;
overflow: hidden;
}
.t-cp-fill {
height: 100%;
background: #6366f1;
border-radius: 99px;
transition: width .35s cubic-bezier(.4,0,.2,1);
}
/* ── Сетка кнопок ── */
.t-cps {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
gap: 5px;
align-content: start;
overflow-y: auto;
}
.t-cp {
aspect-ratio: 1;
border: 1.5px solid var(--border);
border-radius: 8px;
background: none;
font-size: 11px;
font-weight: 700;
font-family: inherit;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all .12s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px;
}
.t-cp:hover { border-color: #6366f1; color: #6366f1; background: rgba(99,102,241,.06); }
.t-cp.done { background: #6366f1; color: #fff; border-color: #6366f1; }
/* ── gh-1: только бар, точки скрыты ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 10px 8px; }
.t-cp-bar-row { padding-bottom: 0; flex: 1; align-items: center; }
.t-cp-fraction { font-size: 20px; }
.t-cps { display: none; }
}
/* ── gh-2 (240px): стандарт ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-cp-fraction { font-size: 14px; }
.t-cps { grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 4px; }
.t-cp { font-size: 10px; border-radius: 6px; }
}
/* ── gh-3/4: подписи ── */
@container metric (min-height: 360px) {
.t-cp-fraction { font-size: 16px; }
.t-cps { grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); gap: 6px; }
.t-cp { aspect-ratio: unset; padding: 6px 4px; font-size: 12px; min-height: 40px; }
}
/* ── gw-2/3: точки крупнее ── */
@container metric (min-width: 260px) {
.t-cps { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); }
}
@container metric (min-width: 380px) {
.t-cps { grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); }
.t-cp { font-size: 12px; }
}

View file

@ -0,0 +1,118 @@
/* ═══════════════════════════════════════════════════════════════════════════
COUNTER — счётчик
Акцент: #f59e0b (amber)
gh-1: − цифра + кнопки в одну строку, компактно.
gh-2: кнопки круглые по краям, цифра крупная по центру.
gh-3/4: кнопки огромные, цифра гигантская, бар снизу.
═══════════════════════════════════════════════════════════════════════════ */
.t-counter-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.t-counter-main {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 0;
}
.t-counter-btn {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid var(--border);
background: none;
font-size: 22px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink);
font-family: inherit;
transition: all .12s;
}
.t-counter-btn:hover {
border-color: #f59e0b;
color: #f59e0b;
background: rgba(245,158,11,.08);
transform: scale(1.05);
}
.t-counter-btn:active { transform: scale(.95); }
.t-counter-display {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
}
.t-counter-val {
font-size: 42px;
font-weight: 700;
font-family: var(--mono);
color: #f59e0b;
line-height: 1;
white-space: nowrap;
}
.t-counter-sub {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
white-space: nowrap;
}
/* Прогресс-бар (если есть макс) */
.t-counter-bar-row {
flex-shrink: 0;
height: 5px;
background: var(--surface2);
border-radius: 99px;
overflow: hidden;
}
.t-counter-bar-fill {
height: 100%;
background: #f59e0b;
border-radius: 99px;
transition: width .35s cubic-bezier(.4,0,.2,1);
}
/* ── gh-1: компактная строка ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 8px 8px; }
.t-counter-main { gap: 6px; }
.t-counter-btn { width: 28px; height: 28px; font-size: 16px; border-width: 1.5px; }
.t-counter-val { font-size: 26px; }
.t-counter-sub { display: none; }
.t-counter-bar-row { height: 3px; }
}
/* ── gh-2: стандарт ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-counter-btn { width: 36px; height: 36px; font-size: 20px; }
.t-counter-val { font-size: 38px; }
}
/* ── gh-3/4: всё крупно ── */
@container metric (min-height: 360px) {
.t-counter-btn { width: 60px; height: 60px; font-size: 30px; border-width: 2.5px; }
.t-counter-val { font-size: 72px; }
.t-counter-sub { font-size: 14px; }
.t-counter-bar-row { height: 8px; }
}
/* ── gw-2/3: больше места — кнопки крупнее ── */
@container metric (min-width: 260px) {
.t-counter-btn { width: 48px; height: 48px; font-size: 26px; }
.t-counter-val { font-size: 52px; }
}

View file

@ -0,0 +1,128 @@
/* ═══════════════════════════════════════════════════════════════════════════
DEADLINES — дедлайны
Акцент: #ec4899 (pink)
gh-1: дробь + бар. Список скрыт.
gh-2+: бар + список с датами.
gh-3/4: строки просторнее, даты видны.
═══════════════════════════════════════════════════════════════════════════ */
.t-dl-bar-row {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-bottom: 6px;
}
.t-dl-track {
flex: 1;
height: 5px;
background: var(--surface2);
border-radius: 99px;
overflow: hidden;
}
.t-dl-fill {
height: 100%;
background: #ec4899;
border-radius: 99px;
transition: width .35s cubic-bezier(.4,0,.2,1);
}
.t-dl-frac {
font-size: 11px;
font-weight: 600;
font-family: var(--mono);
color: #ec4899;
white-space: nowrap;
flex-shrink: 0;
}
.t-dl-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.t-dl-item {
display: flex;
align-items: center;
gap: 7px;
cursor: pointer;
padding: 4px 2px;
border-radius: 6px;
transition: background .1s;
flex-shrink: 0;
min-height: 28px;
}
.t-dl-item:hover { background: var(--surface2); }
.t-dl-item input[type=checkbox] { display: none; }
.t-dl-box {
width: 16px;
height: 16px;
border-radius: 5px;
border: 1.5px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all .12s;
color: transparent;
}
.t-dl-item.done .t-dl-box { background: #ec4899; border-color: #ec4899; color: #fff; }
.t-dl-name {
font-size: 12px;
color: var(--ink);
flex: 1;
min-width: 0;
line-height: 1.3;
transition: all .12s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t-dl-item.done .t-dl-name { color: var(--muted); text-decoration: line-through; }
.t-dl-date {
font-size: 9.5px;
color: var(--muted);
font-family: var(--mono);
flex-shrink: 0;
white-space: nowrap;
}
/* ── gh-1: только бар ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 10px 8px; }
.t-dl-bar-row { flex: 1; align-items: center; margin-bottom: 0; }
.t-dl-frac { font-size: 18px; }
.t-dl-track { height: 8px; }
.t-dl-list { display: none; }
}
/* ── gh-2 ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-dl-date { display: none; }
.t-dl-item { min-height: 24px; }
.t-dl-name { font-size: 11.5px; }
.t-dl-box { width: 14px; height: 14px; }
}
/* ── gh-3/4 ── */
@container metric (min-height: 360px) {
.t-dl-date { display: block; font-size: 10px; }
.t-dl-item { min-height: 32px; padding: 5px 4px; }
.t-dl-box { width: 18px; height: 18px; border-radius: 6px; }
.t-dl-name { font-size: 13px; }
.t-dl-track { height: 6px; }
}
/* ── gw-2/3 ── */
@container metric (min-width: 280px) {
.t-dl-date { display: block; }
.t-dl-name { font-size: 12.5px; }
}

View file

@ -0,0 +1,107 @@
/* ═══════════════════════════════════════════════════════════════════════════
EFFORT HOURS — трудозатраты
Акцент: #14b8a6 (teal)
gh-1: "X ч" крупно по центру, инпут компактно справа.
gh-2: число слева, бар прогресса + инпут справа.
gh-3/4: число огромное, бар толстый, инпут широкий.
═══════════════════════════════════════════════════════════════════════════ */
.t-ef-wrap {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
gap: 12px;
}
/* Левая часть — число */
.t-ef-left {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-shrink: 0;
}
.t-ef-num {
font-size: 40px;
font-weight: 700;
font-family: var(--mono);
color: #14b8a6;
line-height: 1;
}
.t-ef-unit { font-size: 18px; color: #14b8a6; }
.t-ef-goal { font-size: 10px; color: var(--muted); margin-top: 2px; }
/* Правая часть — бар + инпут */
.t-ef-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
.t-ef-bar-wrap {
position: relative;
height: 18px;
background: var(--surface2);
border-radius: 8px;
overflow: hidden;
}
.t-ef-bar-fill {
height: 100%;
background: #14b8a6;
border-radius: 8px;
transition: width .35s cubic-bezier(.4,0,.2,1);
}
.t-ef-bar-pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: var(--ink);
mix-blend-mode: overlay;
pointer-events: none;
}
.t-ef-input-row { display: flex; gap: 5px; }
.t-ef-input-row .t-input { flex: 1; min-width: 0; }
/* ── gh-1: число + инпут горизонтально ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 10px 8px; }
.t-ef-wrap { gap: 10px; }
.t-ef-num { font-size: 28px; }
.t-ef-unit { font-size: 14px; }
.t-ef-goal { display: none; }
.t-ef-bar-wrap { height: 14px; }
.t-ef-bar-pct { font-size: 9px; }
.t-ef-input-row .t-input { font-size: 11px; height: 26px; padding: 2px 6px; }
.t-ef-input-row .t-btn { height: 26px; padding: 0 8px; }
}
/* ── gh-2: стандарт ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-ef-num { font-size: 36px; }
.t-ef-unit { font-size: 16px; }
}
/* ── gh-3/4 ── */
@container metric (min-height: 360px) {
.t-ef-wrap { flex-direction: column; align-items: center; gap: 14px; }
.t-ef-left { align-items: center; }
.t-ef-num { font-size: 64px; }
.t-ef-unit { font-size: 28px; }
.t-ef-goal { font-size: 12px; }
.t-ef-right { width: 100%; }
.t-ef-bar-wrap { height: 24px; border-radius: 12px; }
.t-ef-bar-pct { font-size: 11px; }
}
/* ── gw-2/3 ── */
@container metric (min-width: 280px) and (max-height: 359px) {
.t-ef-num { font-size: 48px; }
.t-ef-unit { font-size: 22px; }
}

View file

@ -0,0 +1,88 @@
/* ═══════════════════════════════════════════════════════════════════════════
GRADES — оценки
Акцент: #f97316 (orange)
gh-1: только мини-бары с названиями, инпуты скрыты.
gh-2+: бары + инпуты + кнопка сохранить.
gh-3/4: строки просторнее.
═══════════════════════════════════════════════════════════════════════════ */
.t-gr-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 5px;
overflow-y: auto;
}
.t-gr-row {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.t-gr-name {
font-size: 10.5px;
color: var(--muted);
width: 36px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.t-gr-track {
flex: 1;
height: 5px;
background: var(--surface2);
border-radius: 99px;
overflow: hidden;
min-width: 0;
}
.t-gr-fill {
height: 100%;
background: #f97316;
border-radius: 99px;
transition: width .35s cubic-bezier(.4,0,.2,1);
}
.t-gr-input {
width: 46px;
flex-shrink: 0;
font-size: 11px !important;
}
/* Кнопка */
.t-gr-save {
flex-shrink: 0;
}
/* ── gh-1: только бары без инпутов ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 10px 8px; }
.t-gr-input { display: none; }
.t-gr-save { display: none; }
.t-gr-name { font-size: 10px; }
.t-gr-track { height: 7px; }
.t-gr-row { gap: 8px; }
}
/* ── gh-2 ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-gr-row { gap: 5px; }
.t-gr-name { font-size: 10px; width: 32px; }
.t-gr-input { width: 40px; }
}
/* ── gh-3/4 ── */
@container metric (min-height: 360px) {
.t-gr-row { gap: 8px; min-height: 30px; }
.t-gr-name { font-size: 12px; width: 48px; }
.t-gr-track { height: 7px; }
.t-gr-input { width: 54px; font-size: 12px !important; }
}
/* ── gw-2/3 ── */
@container metric (min-width: 300px) {
.t-gr-name { width: 52px; font-size: 11px; }
.t-gr-input { width: 52px; }
}

View file

@ -0,0 +1,110 @@
/* ═══════════════════════════════════════════════════════════════════════════
PROGRESS BAR — накопление баллов
Акцент: #0ea5e9 (sky)
gh-1 (120px): кольцо SVG по центру, инпут и кнопка под ним компактно.
gh-2 (240px): кольцо слева, справа — значение + инпут + кнопка.
gh-3/4: кольцо крупнее, инпут шире, всё с воздухом.
═══════════════════════════════════════════════════════════════════════════ */
/* ── Layout-обёртка ── */
.t-pb-wrap {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
gap: 12px;
}
/* ── Кольцо ── */
.t-pb-ring-wrap {
position: relative;
flex-shrink: 0;
width: 80px;
height: 80px;
}
.t-pb-ring {
width: 100%;
height: 100%;
transform: rotate(-90deg);
overflow: visible;
}
.t-pb-ring-bg { fill: none; stroke: var(--surface2); stroke-width: 8; }
.t-pb-ring-fill {
fill: none;
stroke: #0ea5e9;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset .45s cubic-bezier(.4,0,.2,1);
}
.t-pb-pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
font-family: var(--mono);
color: #0ea5e9;
pointer-events: none;
}
/* ── Правая панель ── */
.t-pb-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
.t-pb-val {
font-size: 20px;
font-weight: 700;
font-family: var(--mono);
color: #0ea5e9;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.t-pb-val-max { font-size: 12px; color: var(--muted); font-weight: 400; }
.t-pb-input-row { display: flex; gap: 5px; }
.t-pb-input-row .t-input { flex: 1; min-width: 0; }
/* ── gh-1: кольцо + инпут горизонтально на одной строке ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 8px 8px; }
.t-pb-wrap { gap: 8px; }
.t-pb-ring-wrap { width: 56px; height: 56px; }
.t-pb-pct { font-size: 11px; }
.t-pb-val { font-size: 14px; }
.t-pb-val-max { font-size: 10px; }
.t-pb-input-row .t-input { font-size: 11px; padding: 3px 6px; height: 26px; }
.t-pb-input-row .t-btn { height: 26px; padding: 0 7px; }
}
/* ── gh-2: стандарт ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-pb-ring-wrap { width: 72px; height: 72px; }
.t-pb-pct { font-size: 13px; }
.t-pb-val { font-size: 18px; }
}
/* ── gh-3/4: просторно, кольцо крупнее ── */
@container metric (min-height: 360px) {
.t-pb-wrap { flex-direction: column; align-items: center; gap: 14px; }
.t-pb-ring-wrap { width: 110px; height: 110px; }
.t-pb-pct { font-size: 18px; }
.t-pb-val { font-size: 22px; text-align: center; }
.t-pb-right { width: 100%; align-items: center; }
.t-pb-input-row { width: 100%; }
}
/* ── gw-2/3: кольцо ещё крупнее ── */
@container metric (min-width: 300px) and (min-height: 120px) and (max-height: 359px) {
.t-pb-ring-wrap { width: 90px; height: 90px; }
.t-pb-pct { font-size: 15px; }
.t-pb-val { font-size: 22px; }
}

View file

@ -0,0 +1,107 @@
/* ═══════════════════════════════════════════════════════════════════════════
RATING HISTORY — история оценок
Акцент: #6366f1 (indigo)
gh-1: последняя оценка крупно по центру.
gh-2: оценка слева + список строк с барами справа + форма снизу.
gh-3/4: список длиннее, бары толще, форма просторнее.
═══════════════════════════════════════════════════════════════════════════ */
/* gh-1 layout: центр */
.t-rh-center {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.t-rh-big-val { font-size: 48px; font-weight: 700; font-family: var(--mono); color: #6366f1; line-height: 1; }
.t-rh-big-scale { font-size: 14px; color: var(--muted); }
.t-rh-big-avg { font-size: 11px; color: var(--muted); margin-top: 2px; }
/* gh-2+ layout: левый столбец + правый */
.t-rh-wrap {
flex: 1;
min-height: 0;
display: flex;
gap: 10px;
}
.t-rh-left {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 46px;
border-right: 1px solid var(--border);
padding-right: 10px;
}
.t-rh-last-val { font-size: 28px; font-weight: 700; font-family: var(--mono); color: #6366f1; line-height: 1; }
.t-rh-last-scale { font-size: 11px; color: var(--muted); }
.t-rh-avg { font-size: 9.5px; color: var(--muted); margin-top: 4px; white-space: nowrap; }
.t-rh-avg-lbl { font-size: 8.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
.t-rh-right {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.t-rh-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 3px;
overflow-y: auto;
}
.t-rh-row { display: flex; align-items: center; gap: 5px; flex-shrink: 0; }
.t-rh-date { font-size: 9px; color: var(--muted); font-family: var(--mono); width: 58px; flex-shrink: 0; }
.t-rh-track { flex: 1; height: 4px; background: var(--surface2); border-radius: 99px; overflow: hidden; }
.t-rh-fill { height: 100%; background: #6366f1; border-radius: 99px; transition: width .35s cubic-bezier(.4,0,.2,1); }
.t-rh-val { font-size: 10px; font-weight: 600; font-family: var(--mono); width: 18px; text-align: right; flex-shrink: 0; color: #6366f1; }
.t-rh-form { display: flex; gap: 4px; flex-shrink: 0; }
.t-rh-form .t-input:first-child { flex: 1.4; min-width: 0; }
.t-rh-form .t-input:nth-child(2) { flex: 1; min-width: 0; }
/* ── gh-1: только большая оценка ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 10px 8px; }
.t-rh-wrap { display: none; }
.t-rh-center { display: flex; }
}
/* ── gh-2+: скрыть центр, показать wrap ── */
@container metric (min-height: 120px) {
.t-rh-center { display: none; }
.t-rh-wrap { display: flex; }
}
/* ── gh-2 ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-rh-last-val { font-size: 22px; }
.t-rh-left { min-width: 40px; padding-right: 8px; }
.t-rh-date { width: 50px; }
.t-rh-form .t-input { font-size: 11px; }
}
/* ── gh-3/4 ── */
@container metric (min-height: 360px) {
.t-rh-last-val { font-size: 36px; }
.t-rh-avg { font-size: 11px; }
.t-rh-left { min-width: 58px; }
.t-rh-track { height: 6px; }
.t-rh-val { font-size: 11px; width: 22px; }
.t-rh-date { width: 64px; font-size: 10px; }
.t-rh-row { gap: 6px; }
}
/* ── gw-2/3 ── */
@container metric (min-width: 280px) {
.t-rh-last-val { font-size: 32px; }
.t-rh-date { width: 64px; font-size: 10px; }
}

View file

@ -0,0 +1,130 @@
/* ═══════════════════════════════════════════════════════════════════════════
STAGES — этапы
Акцент: #8b5cf6 (violet)
gh-1: только точки горизонтально без подписей, активная — цветная.
gh-2: точки + подписи горизонтально, при overflow wrap в 2 строки.
gh-3/4: точки крупнее, подписи полные, коннекторы толще.
═══════════════════════════════════════════════════════════════════════════ */
.t-stages {
flex: 1;
min-height: 0;
display: flex;
flex-wrap: wrap;
align-content: center;
align-items: flex-start;
overflow: hidden;
gap: 0;
}
.t-stage-item {
display: flex;
align-items: flex-start;
}
/* Кнопка-этап: точка + подпись */
.t-stage-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
background: none;
border: none;
cursor: pointer;
padding: 4px 3px 4px;
font-family: inherit;
min-width: 0;
}
.t-stage-dot {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
color: var(--muted);
flex-shrink: 0;
transition: all .15s;
position: relative;
z-index: 1;
}
.t-stage-lbl {
font-size: 9.5px;
color: var(--muted);
line-height: 1.2;
text-align: center;
max-width: 52px;
white-space: normal;
overflow-wrap: normal;
word-break: normal;
transition: color .15s;
}
/* Горизонтальный коннектор между точками */
.t-stage-conn {
height: 2px;
width: 16px;
min-width: 8px;
background: var(--border);
flex-shrink: 0;
/* центрируется по точке: padding-top кнопки (4px) + (28px/2) = 18px */
margin-top: 18px;
transition: background .15s;
border-radius: 1px;
}
.t-stage-conn.done { background: #8b5cf6; }
/* Состояния */
.t-stage-btn.done .t-stage-dot { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
.t-stage-btn.done .t-stage-lbl { color: var(--ink); }
.t-stage-btn.active .t-stage-dot {
border-color: #8b5cf6;
color: #8b5cf6;
background: rgba(139,92,246,.1);
box-shadow: 0 0 0 3px rgba(139,92,246,.15);
}
.t-stage-btn.active .t-stage-lbl { color: var(--ink); font-weight: 600; }
.t-stage-btn:hover .t-stage-dot { border-color: #8b5cf6; }
/* ── gh-1: только точки, без подписей ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 8px 8px; }
.t-stages { align-content: center; justify-content: center; }
.t-stage-lbl { display: none; }
.t-stage-btn { padding: 4px 2px; }
.t-stage-dot { width: 22px; height: 22px; font-size: 9px; }
.t-stage-conn { width: 12px; margin-top: 15px; }
}
/* ── gh-2: подписи видны ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-stage-dot { width: 26px; height: 26px; font-size: 10px; }
.t-stage-conn { width: 14px; margin-top: 17px; }
.t-stage-lbl { max-width: 48px; font-size: 9px; white-space: normal; }
}
/* ── gh-3/4: крупно ── */
@container metric (min-height: 360px) {
.t-stage-dot { width: 36px; height: 36px; font-size: 12px; }
.t-stage-conn { width: 22px; height: 3px; margin-top: 22px; }
.t-stage-lbl { font-size: 11px; max-width: 64px; white-space: normal; }
.t-stage-btn { gap: 5px; padding: 6px 4px; }
}
/* ── gw-2/3: коннекторы шире ── */
@container metric (min-width: 280px) {
.t-stage-conn { width: 24px; }
.t-stage-dot { width: 30px; height: 30px; }
.t-stage-conn { margin-top: 19px; }
.t-stage-lbl { max-width: 60px; font-size: 10px; white-space: normal; }
}
@container metric (min-width: 400px) {
.t-stage-conn { width: 32px; }
.t-stage-lbl { max-width: 72px; }
}

View file

@ -0,0 +1,72 @@
/* ═══════════════════════════════════════════════════════════════════════════
STREAK — серия
Акцент: #f59e0b (amber)
gh-1: 🔥 + цифра горизонтально, кнопка компактная.
gh-2: огонь + цифра вертикально по центру + кнопка снизу.
gh-3/4: всё огромное.
═══════════════════════════════════════════════════════════════════════════ */
.t-streak-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.t-streak-flame { font-size: 32px; line-height: 1; user-select: none; }
.t-streak-num {
font-size: 52px;
font-weight: 700;
font-family: var(--mono);
color: #f59e0b;
line-height: 1;
}
.t-streak-lbl {
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
}
.t-streak-btn {
flex-shrink: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 11.5px;
}
/* ── gh-1: горизонтально ── */
@container metric (max-height: 119px) {
.t-metric-body { padding: 0 10px 8px; }
.t-streak-body { flex-direction: row; gap: 8px; justify-content: center; }
.t-streak-flame { font-size: 24px; }
.t-streak-num { font-size: 34px; }
.t-streak-lbl { display: none; }
.t-streak-btn { width: auto; font-size: 10.5px; flex-shrink: 0; }
}
/* ── gh-2 ── */
@container metric (min-height: 120px) and (max-height: 239px) {
.t-streak-flame { font-size: 28px; }
.t-streak-num { font-size: 44px; }
.t-streak-lbl { font-size: 10px; }
}
/* ── gh-3/4 ── */
@container metric (min-height: 360px) {
.t-streak-flame { font-size: 48px; }
.t-streak-num { font-size: 72px; }
.t-streak-lbl { font-size: 14px; }
.t-streak-btn { font-size: 13px; padding: 8px; }
}
/* ── gw-2/3 ── */
@container metric (min-width: 260px) {
.t-streak-flame { font-size: 36px; }
.t-streak-num { font-size: 60px; }
}

63
app/static/css/nav.css Normal file
View file

@ -0,0 +1,63 @@
/* ═══ Навбар ════════════════════════════════════════════════════════════════ */
.t-nav {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 28px;
height: 54px;
display: flex;
align-items: center;
gap: 12px;
position: sticky;
top: 0;
z-index: 100;
}
.t-brand {
font-weight: 800;
font-size: 16px;
letter-spacing: -.04em;
color: var(--ink);
display: flex;
align-items: center;
line-height: 1;
}
.t-brand-tab {
color: var(--ink);
}
.t-brand-lo {
background: var(--green);
color: #fff;
border-radius: 5px;
padding: 2px 5px 3px;
margin-left: 1px;
line-height: 1;
}
.t-nav-r {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
/* Все кнопки в правой части навбара — одинаковая высота */
.t-nav-r .t-btn,
.t-nav-r .t-btn-icon {
height: 32px;
}
.t-nav-r .t-btn {
padding: 0 14px;
font-size: 12.5px;
}
.t-nav-r .t-btn-icon {
padding: 0 8px;
width: auto;
}
.t-nav-user { font-size: 12px; color: var(--muted); }
/* Переключатель темы — иконки */
.t-theme-toggle { font-size: 15px; }
[data-theme="dark"] .t-icon-sun { display: block; }
[data-theme="dark"] .t-icon-moon { display: none; }
[data-theme="light"] .t-icon-sun { display: none; }
[data-theme="light"] .t-icon-moon { display: block; }
.t-icon-sun, .t-icon-moon { display: none; }

279
app/static/css/schedule.css Normal file
View file

@ -0,0 +1,279 @@
/* ═══ Сетка дней ══════════════════════════════════════════════════════════════ */
.t-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
@media (max-width: 900px) { .t-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 580px) { .t-grid { grid-template-columns: 1fr; } }
/* ═══ Карточка дня ═══════════════════════════════════════════════════════════ */
.t-day-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.t-day-card.today { border-color: var(--green); }
.t-day-card-head {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
display: flex;
align-items: center;
gap: 8px;
}
.t-day-card-head.today { background: var(--mint); }
.t-day-name { font-size: 13px; font-weight: 600; }
.t-day-date { margin-left: auto; font-size: 11.5px; color: var(--muted); font-family: var(--mono); }
/* ═══ Слот расписания ════════════════════════════════════════════════════════ */
.t-slot {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 10px;
cursor: pointer;
transition: background .1s;
}
.t-slot:last-of-type { border-bottom: none; }
.t-slot:hover { background: var(--surface2); }
.t-slot.open { background: var(--surface2); }
.t-slot-time {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
min-width: 66px;
padding-top: 2px;
line-height: 1.4;
flex-shrink: 0;
}
.t-slot-body { flex: 1; min-width: 0; overflow: hidden; }
.t-slot-name {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.t-slot-room {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
display: flex;
align-items: center;
gap: 3px;
}
.t-slot-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.t-slot-edit-controls { display: none; gap: 2px; }
body.edit-mode .t-slot-edit-controls { display: flex; }
/* Стрелка разворота */
.t-chevron {
font-size: 10px;
color: #ccc;
transition: transform .2s;
line-height: 1;
}
.t-slot.open .t-chevron { transform: rotate(180deg); }
/* ═══ Детали слота ════════════════════════════════════════════════════════════ */
.t-slot-detail {
display: none;
padding: 10px 14px 12px 90px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.t-slot.open + .t-slot-detail { display: block; }
.t-slot-teacher {
font-size: 11.5px;
color: var(--muted);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 4px;
}
/* Группа слотов — все предметы сразу видны */
.t-slot-group {
padding: 8px 14px;
border-bottom: 1px solid var(--border);
}
.t-slot-group-time {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.t-slot-group-badge {
font-size: 10px;
font-weight: 600;
color: var(--muted);
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1px 7px;
white-space: nowrap;
}
.t-slot-group-items {
border-left: 2px solid var(--border);
margin-left: 7px;
display: flex;
flex-direction: column;
gap: 0;
}
.t-slot-group-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0 5px 10px;
transition: background .1s;
border-radius: 0 6px 6px 0;
}
.t-slot-group-item:hover { background: var(--surface2); }
.flex-1 { flex: 1; }
.t-slot-time-mono { font-size: 11px; font-family: var(--mono); }
/* ═══ Детали слота — описание и трекеры ══════════════════════════════════════ */
.t-slot-desc {
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
line-height: 1.5;
}
.t-slot-metrics {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.t-slot-metric-link {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--surface);
cursor: pointer;
transition: border-color .12s, color .12s;
text-decoration: none;
}
.t-slot-metric-link:hover {
border-color: var(--green);
color: var(--green);
}
/* Кнопка СДО в детали слота */
.t-slot-sdo-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--surface);
text-decoration: none;
transition: border-color .12s, color .12s;
}
.t-slot-sdo-btn:hover { border-color: var(--accent, #6366f1); color: var(--accent, #6366f1); }
/* Бейдж «Завершён» в детали слота */
.t-slot-completed-badge {
display: inline-flex;
align-items: center;
padding: 0 6px;
border-radius: 99px;
font-size: 10px;
font-weight: 600;
background: rgba(34, 197, 94, .12);
color: #16a34a;
border: 1px solid rgba(34, 197, 94, .3);
margin-left: 4px;
}
[data-theme="dark"] .t-slot-completed-badge {
color: #4ade80;
background: rgba(34, 197, 94, .18);
border-color: rgba(34, 197, 94, .35);
}
/* ═══ Дэшборд ═════════════════════════════════════════════════════════════════ */
/* Использует общий .t-metrics-grid из metrics.css */
.t-dash-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 32px;
margin-bottom: 12px;
}
.t-dash-title-actions { display: flex; align-items: center; gap: 8px; }
/* Заполненные трекеры — скрыты по умолчанию, показываются по кнопке. */
.t-metric.metric-filled-hidden { display: none !important; }
/* Промт AI-трекера: заголовок кликабельный */
.t-metric-title-clickable {
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.t-metric-title-clickable:hover { opacity: .8; }
/* Блок отображения промта в модальном окне */
.t-ai-prompt-display {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
margin-top: 8px;
min-height: 40px;
}
/* ═══ Карточки расписаний (профиль) ══════════════════════════════════════════ */
.t-sched-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
@media (max-width: 900px) { .t-sched-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 580px) { .t-sched-grid { grid-template-columns: 1fr; } }
.t-sched-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
transition: box-shadow .15s, border-color .15s;
}
.t-sched-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.15); border-color: var(--muted); }
.t-sched-card-title { font-size: 15px; font-weight: 700; }
.t-sched-card-meta { font-size: 11.5px; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; }
.t-sched-card-meta span { display: flex; align-items: center; gap: 3px; }
.t-sched-card-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
.t-sched-card-footer { border-top: 1px solid var(--border); padding-top: 10px; margin-top: 4px; }
/* Объединение расписаний */
.t-merge-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.t-merge-title { margin-bottom: 12px; }

235
app/static/css/subjects.css Normal file
View file

@ -0,0 +1,235 @@
/* ═══ Сетка карточек предметов ══════════════════════════════════════════════ */
.t-subj-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ═══ Карточка предмета ═════════════════════════════════════════════════════ */
.t-subj-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
/* Шапка карточки — в стиле t-day-card-head */
.t-subj-head {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
display: flex;
align-items: center;
gap: 10px;
}
.t-subj-head-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.t-subj-name {
font-size: 14px;
font-weight: 700;
letter-spacing: -.01em;
}
.t-subj-type-dots { display: flex; gap: 4px; flex-wrap: wrap; }
.t-subj-head-actions { display: flex; gap: 2px; flex-shrink: 0; }
/* Скрытый предмет — приглушённый вид карточки */
.t-subj-card.subject-hidden { opacity: .5; }
.t-subj-card.subject-hidden .t-subj-head { background: repeating-linear-gradient(
135deg, transparent, transparent 4px, rgba(128,128,128,.04) 4px, rgba(128,128,128,.04) 8px); }
/* Кнопка видимости: активная (скрыто) — красноватый оттенок */
.toggle-visibility-btn.subject-hidden { color: #ef4444 !important; }
/* Секция внутри карточки */
.t-subj-section {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.t-subj-section:last-child { border-bottom: none; }
.t-subj-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.t-subj-section-head .t-subj-section-title { margin-bottom: 0; }
.t-subj-section-title {
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .04em;
margin-bottom: 8px;
}
/* Описание */
.t-subj-desc { font-size: 13px; margin-bottom: 6px; line-height: 1.5; }
.t-desc-italic { font-style: italic; }
.t-subject-desc-form { display: none; margin-top: 6px; }
.t-subject-desc-form.open { display: block; }
.t-subj-desc-actions { display: flex; gap: 6px; margin-top: 6px; }
/* Кнопки полей (переименование) */
.t-subj-field-btns { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
/* Добавить трекер */
.t-subj-add-metric { margin-top: 8px; }
.t-add-metric-btns { display: flex; gap: 6px; }
.t-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
/* Заголовок секции слотов (тип + период) */
.t-subj-slots-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.t-subj-period-form { display: flex; }
.t-subj-period-form .t-input-group { width: 200px; }
/* Строка редактирования слота */
.t-slot-edit-row td { padding: 3px 6px; }
.t-slot-edit-row .t-input-sm { padding: 3px 7px; font-size: 12px; }
.t-slot-edit-save-row { display: flex; gap: 2px; }
/* Таблица слотов — нет верхнего отступа */
.t-subj-slot-table { margin-top: 0; }
/* Legacy-классы (используются в JS) */
.t-subject-card { border-bottom: 1px solid var(--border); }
.t-subject-name { font-weight: 600; font-size: 13px; }
.t-desc-text { font-size: 12.5px; margin-bottom: 6px; }
/* ═══ Страница предметов (legacy-обёртка) ════════════════════════════════════ */
.t-subjects-page {
max-width: 900px;
margin: 0 auto;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: var(--surface);
}
.t-subject-card-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--surface2);
cursor: pointer;
transition: background .12s;
}
.t-subject-card-head:hover { background: var(--bg); }
.t-subject-body { padding: 12px 16px; }
.t-subject-collapse { display: none; }
.t-subject-collapse.open { display: block; }
.t-subject-close-row { margin-left: auto; }
.t-subject-fields-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.t-subject-desc-edit { display: flex; gap: 6px; margin-top: 6px; }
.t-add-metric-btns { display: flex; gap: 6px; }
/* ═══ Завершённость предмета ════════════════════════════════════════════════ */
.t-subj-card.subject-completed { border-color: var(--color-green, #22c55e); }
.t-subj-card.subject-completed .t-subj-head {
background: color-mix(in srgb, var(--color-green, #22c55e) 8%, var(--surface2));
}
.t-completed-badge {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 99px;
font-size: 10px;
font-weight: 600;
background: rgba(34, 197, 94, .12);
color: #16a34a;
border: 1px solid rgba(34, 197, 94, .3);
}
[data-theme="dark"] .t-completed-badge {
color: #4ade80;
background: rgba(34, 197, 94, .18);
border-color: rgba(34, 197, 94, .35);
}
.toggle-completed-btn.is-completed { color: #22c55e !important; }
/* ═══ Тип сдачи: бейдж ══════════════════════════════════════════════════════ */
.t-completion-badge {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 99px;
font-size: 10px;
font-weight: 600;
background: rgba(167, 139, 250, .12);
color: #7c3aed;
border: 1px solid rgba(167, 139, 250, .3);
}
[data-theme="dark"] .t-completion-badge {
color: #c4b5fd;
background: rgba(167, 139, 250, .18);
border-color: rgba(167, 139, 250, .35);
}
/* ═══ Мета-блок (тип сдачи + СДО) ══════════════════════════════════════════ */
.t-subj-meta-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.t-subj-meta-item {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 130px;
}
.t-subj-meta-item.t-subj-meta-sdo { flex: 1; }
.t-subj-meta-label {
font-size: 10px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .04em;
}
.t-subj-meta-value { font-size: 13px; }
.t-subj-sdo-edit { display: flex; gap: 4px; align-items: center; }
.t-subj-sdo-edit .sdo-url-input { flex: 1; min-width: 0; }
.t-subj-sdo-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--accent, #6366f1);
text-decoration: none;
}
.t-subj-sdo-link:hover { text-decoration: underline; }
/* ═══ Таблица слотов ════════════════════════════════════════════════════════ */
.t-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.t-table th {
text-align: left;
font-weight: 600;
color: var(--muted);
font-size: 11px;
padding: 4px 6px;
border-bottom: 1px solid var(--border);
}
.t-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.t-table tr:last-child td { border-bottom: none; }
.t-table-actions { width: 52px; }
.t-table-actions-cell { display: none; gap: 2px; }
body.edit-mode .t-table-actions-cell { display: flex; }
/* Добавить трекер */
.t-add-metric-row { margin-top: 8px; display: flex; gap: 6px; }
.t-metric-form { margin-top: 8px; display: none; }
.t-metric-form.open { display: block; }
.t-metric-input-row { display: flex; gap: 6px; }
.t-ai-submit { border-radius: 0 8px 8px 0; }

BIN
app/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

4
app/static/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="5" fill="#10b981"/>
<text x="16" y="16" font-family="system-ui, -apple-system, sans-serif" font-weight="600" font-size="24" fill="#ffffff" letter-spacing="-0.04em" text-anchor="middle" dominant-baseline="central">lo</text>
</svg>

After

Width:  |  Height:  |  Size: 350 B

86
app/static/js/core.js Normal file
View file

@ -0,0 +1,86 @@
// ─── CSRF ────────────────────────────────────────────────────────────────────
const csrfToken = () =>
document.querySelector('meta[name="csrf-token"]')?.content || '';
// ─── Fetch-обёртка ───────────────────────────────────────────────────────────
async function apiFetch(url, method = 'GET', body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() },
};
if (body !== null) opts.body = JSON.stringify(body);
let res;
try {
res = await fetch(url, opts);
} catch (e) {
return { error: 'Нет соединения' };
}
if (res.redirected || res.status === 401 || res.status === 302) {
return { error: 'Сессия истекла, обновите страницу' };
}
try {
return await res.json();
} catch {
return { error: `Ошибка сервера (${res.status})` };
}
}
// ─── Модальные окна ──────────────────────────────────────────────────────────
function openModal(id) {
document.getElementById(id)?.classList.add('open');
}
function closeModal(id) {
document.getElementById(id)?.classList.remove('open');
}
// ─── URL-encode ──────────────────────────────────────────────────────────────
function enc(s) { return encodeURIComponent(s); }
// ─── Режим редактирования ────────────────────────────────────────────────────
function initEditMode() {
const btn = document.getElementById('edit-mode-btn');
if (!btn) return;
const key = 'edit-mode-' + (typeof SCHEDULE_ID !== 'undefined' ? SCHEDULE_ID : '');
if (sessionStorage.getItem(key) === '1') {
document.body.classList.add('edit-mode');
btn.classList.add('on');
}
btn.addEventListener('click', () => {
const active = document.body.classList.toggle('edit-mode');
btn.classList.toggle('on', active);
sessionStorage.setItem(key, active ? '1' : '0');
});
}
// ─── Флеш «сохранено» на кнопке ─────────────────────────────────────────────
function flashSaved(btn) {
if (!btn) return;
const orig = btn.textContent;
btn.textContent = '✓';
setTimeout(() => btn.textContent = orig, 1200);
}
// ─── Универсальный модал переименования ─────────────────────────────────────
let _renameCb = null;
function openRenameModal(title, currentVal, cb) {
_renameCb = cb;
document.getElementById('rename-modal-title').textContent = title;
const input = document.getElementById('rename-input');
input.value = currentVal;
openModal('renameModalBackdrop');
setTimeout(() => input.select(), 50);
}
function initRenameModal() {
const confirmBtn = document.getElementById('rename-confirm-btn');
if (!confirmBtn) return;
const doSave = async () => {
const val = document.getElementById('rename-input').value.trim();
if (!val) return;
closeModal('renameModalBackdrop');
if (_renameCb) { await _renameCb(val); _renameCb = null; }
};
confirmBtn.addEventListener('click', doSave);
document.getElementById('rename-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter') doSave();
});
}

631
app/static/js/metrics.js Normal file
View file

@ -0,0 +1,631 @@
// ═══ Ядро: чтение/запись прогресса через data-атрибут ════════════════════════
function getProgress(widget) {
try { return JSON.parse(widget.dataset.progress || '{}'); } catch { return {}; }
}
function setProgress(widget, prog) {
widget.dataset.progress = JSON.stringify(prog);
}
async function saveProgress(widget, prog) {
setProgress(widget, prog);
const id = widget.dataset.metricId;
await apiFetch(`/api/metrics/${id}/progress`, 'POST', prog);
}
// ═══ Инициализация всех виджетов ═════════════════════════════════════════════
function initMetricWidgets() {
document.querySelectorAll('.t-metric').forEach(w => initWidget(w));
}
function initWidget(w) {
if (w._init) return;
w._init = true;
const type = w.dataset.metricType;
const canEdit = typeof CAN_EDIT !== 'undefined' ? CAN_EDIT : true;
w.querySelector('.delete-metric-btn')?.addEventListener('click', async () => {
if (!confirm('Удалить трекер?')) return;
const res = await apiFetch(`/api/metrics/${w.dataset.metricId}`, 'DELETE');
if (res.ok) w.remove();
});
if (!canEdit) return;
if (type === 'checkpoints') {
const total = w.querySelectorAll('.t-cp').length;
w.querySelectorAll('button.t-cp').forEach(btn => {
btn.addEventListener('click', async () => {
const prog = getProgress(w);
const checked = prog.checked || [];
const idx = parseInt(btn.dataset.index);
const pos = checked.indexOf(idx);
if (pos === -1) checked.push(idx); else checked.splice(pos, 1);
prog.checked = checked;
btn.classList.toggle('done', checked.includes(idx));
const done = checked.length;
const fracEl = w.querySelector('.t-cp-fraction');
if (fracEl) fracEl.firstChild.textContent = done;
const fill = w.querySelector('.t-cp-fill');
if (fill && total) fill.style.width = Math.round(done / total * 100) + '%';
await saveProgress(w, prog);
});
});
}
if (type === 'progress_bar') {
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
w.querySelector('.update-progress-btn')?.addEventListener('click', async () => {
const input = w.querySelector('.progress-input');
const val = parseFloat(input.value) || 0;
const mx = parseFloat(cfg.max || 100);
const pct = Math.min(100, Math.round(val / mx * 100));
const ring = w.querySelector('.t-pb-ring-fill');
if (ring) ring.style.strokeDashoffset = ((100 - pct) / 100 * 213.6).toFixed(1);
const pctEl = w.querySelector('.t-pb-pct');
if (pctEl) pctEl.textContent = pct + '%';
const valEl = w.querySelector('.t-pb-val');
if (valEl) valEl.firstChild.textContent = val;
await saveProgress(w, { current: val });
});
}
if (type === 'counter') {
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
const valEl = w.querySelector('.t-counter-val');
const step = parseFloat(cfg.step) || 1;
const update = async delta => {
const prog = getProgress(w);
const max = cfg.max ? parseFloat(cfg.max) : Infinity;
const raw = (prog.current || 0) + delta;
const next = Math.max(0, Math.min(max, Math.round(raw * 1e9) / 1e9));
prog.current = next;
valEl.textContent = next;
if (cfg.max) {
const bar = w.querySelector('.t-counter-bar-fill');
if (bar) bar.style.width = Math.min(100, Math.round(next / parseFloat(cfg.max) * 100)) + '%';
}
await saveProgress(w, prog);
};
w.querySelector('.counter-dec')?.addEventListener('click', () => update(-step));
w.querySelector('.counter-inc')?.addEventListener('click', () => update(+step));
}
if (type === 'stages') {
w.querySelectorAll('.stage-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.index);
w.querySelectorAll('.stage-btn').forEach((b, i) => {
b.classList.toggle('done', i < idx);
b.classList.toggle('active', i === idx);
if (i > idx) b.classList.remove('done', 'active');
});
w.querySelectorAll('.t-stage-conn').forEach((c, i) => {
c.classList.toggle('done', i < idx);
});
await saveProgress(w, { current_stage: idx });
});
});
}
if (type === 'checklist') {
w.querySelectorAll('.checklist-item').forEach(cb => {
cb.addEventListener('change', async () => {
const prog = getProgress(w);
const checked = prog.checked || [];
const idx = parseInt(cb.dataset.index);
const pos = checked.indexOf(idx);
if (cb.checked && pos === -1) checked.push(idx);
if (!cb.checked && pos !== -1) checked.splice(pos, 1);
prog.checked = checked;
cb.closest('.t-cl-item').classList.toggle('checked', cb.checked);
await saveProgress(w, prog);
});
});
}
if (type === 'attendance') {
w.querySelector('.add-att-btn')?.addEventListener('click', async () => {
const dateVal = w.querySelector('.att-date').value;
const status = w.querySelector('.att-status').value;
if (!dateVal) return;
const prog = getProgress(w);
const records = prog.records || [];
records.push({ date: dateVal, status });
prog.records = records;
// Добавить точку в историю
const dot = document.createElement('span');
dot.className = `t-att-dot t-att-dot--${status}`;
dot.title = dateVal;
w.querySelector('.t-att-dots')?.appendChild(dot);
// Обновить счётчик нужного стата
const statMap = { present: 't-att-stat--present', absent: 't-att-stat--absent', excused: 't-att-stat--excused' };
const statEl = w.querySelector(`.${statMap[status]} .t-att-num`);
if (statEl) statEl.textContent = parseInt(statEl.textContent || '0') + 1;
w.querySelector('.att-date').value = '';
await saveProgress(w, prog);
});
}
if (type === 'grades') {
w.querySelector('.save-grades-btn')?.addEventListener('click', async () => {
const prog = getProgress(w);
const grades = {};
w.querySelectorAll('.grade-input').forEach(inp => {
if (inp.value !== '') grades[inp.dataset.work] = parseFloat(inp.value);
});
prog.grades = grades;
await saveProgress(w, prog);
flashSaved(w.querySelector('.save-grades-btn'));
});
}
if (type === 'deadlines') {
w.querySelectorAll('.deadline-item').forEach(cb => {
cb.addEventListener('change', async () => {
const prog = getProgress(w);
const completed = prog.completed || [];
const idx = parseInt(cb.dataset.index);
const pos = completed.indexOf(idx);
if (cb.checked && pos === -1) completed.push(idx);
if (!cb.checked && pos !== -1) completed.splice(pos, 1);
prog.completed = completed;
cb.closest('.t-dl-item').classList.toggle('done', cb.checked);
await saveProgress(w, prog);
});
});
}
if (type === 'streak') {
w.querySelector('.mark-today-btn')?.addEventListener('click', async () => {
const today = new Date().toISOString().split('T')[0];
const prog = getProgress(w);
const records = prog.records || [];
if (records.includes(today)) return;
records.push(today);
prog.records = records;
w.querySelector('.t-streak-num').textContent = records.length;
w.querySelector('.mark-today-btn').disabled = true;
await saveProgress(w, prog);
});
}
if (type === 'effort_hours') {
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
w.querySelector('.add-effort-btn')?.addEventListener('click', async () => {
const add = parseFloat(w.querySelector('.effort-input').value);
if (!add || add <= 0) return;
const prog = getProgress(w);
const total = +(prog.hours || 0) + add;
prog.hours = total;
const numEl = w.querySelector('.t-ef-num');
if (numEl) numEl.childNodes[0].textContent = total;
const goal = parseFloat(cfg.goal_hours);
if (goal) {
const pct = Math.min(100, Math.round(total / goal * 100));
const bar = w.querySelector('.t-ef-bar-fill');
if (bar) bar.style.width = pct + '%';
const pctEl = w.querySelector('.t-ef-bar-pct');
if (pctEl) pctEl.textContent = pct + '%';
}
w.querySelector('.effort-input').value = '';
await saveProgress(w, prog);
});
}
if (type === 'rating_history') {
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
w.querySelector('.add-rating-btn')?.addEventListener('click', async () => {
const dateVal = w.querySelector('.rating-date').value;
const grade = parseFloat(w.querySelector('.rating-grade').value);
if (!dateVal || isNaN(grade)) return;
const prog = getProgress(w);
const entries = prog.entries || [];
entries.push({ date: dateVal, grade });
prog.entries = entries;
const scale = cfg.scale || 5;
const list = w.querySelector('.t-rh-list');
const pct = Math.round(grade / scale * 100);
const row = document.createElement('div');
row.className = 't-rh-row';
row.innerHTML = `<span class="t-rh-date">${dateVal}</span>`
+ `<div class="t-rh-track"><div class="t-rh-fill" style="width:${pct}%"></div></div>`
+ `<span class="t-rh-val">${grade}</span>`;
// Удалить «Нет записей» если было
list.querySelector('.t-metric-empty')?.remove();
list.prepend(row);
// Обновить summary — последняя оценка и среднее
const avg = (entries.reduce((s, e) => s + e.grade, 0) / entries.length).toFixed(1);
['.t-rh-last-val', '.t-rh-big-val'].forEach(sel => {
const el = w.querySelector(sel);
if (el) el.textContent = grade;
});
['.t-rh-avg', '.t-rh-big-avg'].forEach(sel => {
const el = w.querySelector(sel);
if (el) el.textContent = el.textContent.replace(/[\d.]+/, avg);
});
w.querySelector('.rating-date').value = '';
w.querySelector('.rating-grade').value = '';
await saveProgress(w, prog);
});
}
}
// ═══ Форма добавления трекера ════════════════════════════════════════════════
const METRIC_SCHEMA = {
checkpoints: [{ name: 'total', label: 'Количество точек', type: 'number', ph: '10' },
{ name: 'labels', label: 'Подписи через запятую (опц.)', type: 'text', ph: 'КТ1,КТ2' }],
progress_bar: [{ name: 'max', label: 'Максимум', type: 'number', ph: '100' },
{ name: 'unit', label: 'Единица', type: 'text', ph: 'баллов' },
{ name: 'step', label: 'Шаг', type: 'number', ph: '1' }],
counter: [{ name: 'max', label: 'Максимум (0 = ∞)', type: 'number', ph: '0' },
{ name: 'step', label: 'Шаг', type: 'number', ph: '1' },
{ name: 'unit', label: 'Единица', type: 'text', ph: 'раз' }],
stages: [{ name: 'stages', label: 'Этапы через запятую', type: 'text', ph: 'Старт,В работе,Сдано' }],
checklist: [{ name: 'items', label: 'Пункты, каждый с новой строки', type: 'textarea', ph: 'Пункт 1\nПункт 2' }],
attendance: [],
grades: [{ name: 'works', label: 'Работы: имя=вес, через запятую', type: 'text', ph: 'ЛР1=1,ЛР2=2' },
{ name: 'max_grade', label: 'Макс. балл', type: 'number', ph: '5' }],
deadlines: [{ name: 'items', label: 'Дедлайны: имя|ГГГГ-ММ-ДД, по строке', type: 'textarea', ph: 'Курсовая|2026-05-20' }],
streak: [],
effort_hours: [{ name: 'goal_hours', label: 'Цель (часов)', type: 'number', ph: '40' }],
rating_history: [{ name: 'scale', label: 'Шкала (макс. балл)', type: 'number', ph: '5' }],
};
function buildConfig(type, form) {
const schema = METRIC_SCHEMA[type] || [];
const cfg = {};
for (const f of schema) {
const el = form.querySelector(`[data-cfg="${f.name}"]`);
const val = el?.value.trim();
if (!val) continue;
if (type === 'checkpoints' && f.name === 'labels') { cfg.labels = val.split(',').map(s => s.trim()).filter(Boolean); continue; }
if (type === 'stages' && f.name === 'stages') { cfg.stages = val.split(',').map(s => s.trim()).filter(Boolean); continue; }
if (type === 'checklist' && f.name === 'items') { cfg.items = val.split('\n').map(s => s.trim()).filter(Boolean); continue; }
if (type === 'grades' && f.name === 'works') {
cfg.works = val.split(',').map(s => { const [n, w] = s.split('=').map(x => x.trim()); return { name: n, weight: parseFloat(w) || 1 }; });
continue;
}
if (type === 'deadlines' && f.name === 'items') {
cfg.items = val.split('\n').map(s => { const [n, d] = s.split('|').map(x => x.trim()); return { name: n, date: d || '' }; }).filter(x => x.name);
continue;
}
cfg[f.name] = f.type === 'number' ? parseFloat(val) || 0 : val;
}
return cfg;
}
function renderConfigFields(type, area) {
area.innerHTML = '';
for (const f of METRIC_SCHEMA[type] || []) {
const wrap = document.createElement('div');
wrap.className = 't-field';
const lbl = document.createElement('label');
lbl.className = 't-label';
lbl.textContent = f.label;
lbl.htmlFor = `cfg-${f.name}`;
wrap.appendChild(lbl);
let el;
if (f.type === 'textarea') {
el = document.createElement('textarea');
el.rows = 3;
el.className = 't-input t-textarea';
} else {
el = document.createElement('input');
el.type = f.type;
el.className = 't-input t-input-sm';
}
el.placeholder = f.ph || '';
el.dataset.cfg = f.name;
el.id = `cfg-${f.name}`;
wrap.appendChild(el);
area.appendChild(wrap);
}
}
function initAddMetric() {
document.querySelectorAll('.add-metric-section').forEach(section => {
const sid = section.dataset.schedule;
const subject = section.dataset.subject;
const slotId = section.dataset.slot;
const container = document.getElementById(`metrics-${slotId}`);
const aiForm = section.querySelector('.ai-metric-form');
const manForm = section.querySelector('.manual-metric-form');
section.querySelector('.ai-metric-btn')?.addEventListener('click', () => {
aiForm.classList.toggle('open');
manForm.classList.remove('open');
});
section.querySelector('.manual-metric-btn')?.addEventListener('click', () => {
manForm.classList.toggle('open');
aiForm.classList.remove('open');
});
const typeSelect = section.querySelector('.metric-type-select');
const configArea = section.querySelector('.metric-config-area');
typeSelect?.addEventListener('change', () => renderConfigFields(typeSelect.value, configArea));
// AI
section.querySelector('.ai-prompt-submit')?.addEventListener('click', async () => {
const prompt = section.querySelector('.ai-prompt-input').value.trim();
if (!prompt) return;
const submitBtn = section.querySelector('.ai-prompt-submit');
submitBtn.innerHTML = '<span class="t-spinner"></span>';
submitBtn.disabled = true;
const res = await apiFetch(
`/schedules/${sid}/subjects/${enc(subject)}/metrics/ai`, 'POST', { prompt }
);
submitBtn.innerHTML = '<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
submitBtn.disabled = false;
if (res.ok && container) {
container.insertAdjacentHTML('beforeend', res.html);
const newEl = container.lastElementChild;
initWidget(newEl);
gridRegisterNew(newEl, container);
aiForm.classList.remove('open');
section.querySelector('.ai-prompt-input').value = '';
} else alert(res.error || 'Ошибка');
});
// Вручную
section.querySelector('.manual-metric-submit')?.addEventListener('click', async () => {
const type = typeSelect?.value;
const label = section.querySelector('.metric-label-input').value.trim();
if (!type) { alert('Выберите тип'); return; }
const cfg = buildConfig(type, section);
const res = await apiFetch(
`/schedules/${sid}/subjects/${enc(subject)}/metrics`, 'POST',
{ type, label, config: cfg }
);
if (res.ok && container) {
container.insertAdjacentHTML('beforeend', res.html);
const newEl = container.lastElementChild;
initWidget(newEl);
gridRegisterNew(newEl, container);
manForm.classList.remove('open');
section.querySelector('.metric-label-input').value = '';
if (typeSelect) typeSelect.value = '';
configArea.innerHTML = '';
} else alert(res.error || 'Ошибка');
});
});
}
// ═══ Grid-редактор: размер и перетаскивание трекеров ═════════════════════════
const GRID_COLS = 3; // колонки (совпадает с --mg-cols в CSS)
const GRID_MAX_W = 3; // макс. ширина в колонках
const GRID_MAX_H = 4; // макс. высота в полустроках (4 = 2 строки)
// Применить размерные классы gw-N / gh-N (N = полустроки для высоты)
function applyGridSize(el) {
const w = Math.max(1, Math.min(GRID_MAX_W, parseInt(el.dataset.gridW) || 1));
const h = Math.max(1, Math.min(GRID_MAX_H, parseInt(el.dataset.gridH) || 2));
el.classList.remove('gw-2', 'gw-3', 'gh-1', 'gh-2', 'gh-3', 'gh-4');
if (w > 1) el.classList.add(`gw-${w}`);
el.classList.add(`gh-${h}`);
}
// ─── Сохранение позиции/размера в БД ─────────────────────────────────────────
async function saveLayout(metricId, col, row, w, h) {
await apiFetch(`/api/metrics/${metricId}/layout`, 'POST',
{ grid_col: col, grid_row: row, grid_w: w, grid_h: h });
}
// ─── Resize (правый нижний угол) ──────────────────────────────────────────────
function initResize(el, grid) {
const handle = el.querySelector('.t-metric-resize');
if (!handle) return;
handle.addEventListener('pointerdown', e => {
if (!grid.classList.contains('drag-mode')) return;
e.preventDefault();
e.stopPropagation();
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startY = e.clientY;
const startW = parseInt(el.dataset.gridW) || 1;
const startH = parseInt(el.dataset.gridH) || 2;
// Ширина одной колонки; высота одной полустроки из CSS-переменной
const gap = parseFloat(getComputedStyle(grid).getPropertyValue('--mg-gap')) || 12;
const colW = (grid.offsetWidth - gap * (GRID_COLS - 1)) / GRID_COLS;
const rowHpx = parseFloat(getComputedStyle(grid).getPropertyValue('--row-h')) || 120;
function onMove(ev) {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
const col = parseInt(el.dataset.gridCol) || 0;
const maxW = Math.min(GRID_MAX_W, GRID_COLS - col);
const newW = Math.max(1, Math.min(maxW, Math.round(startW + dx / (colW + gap))));
const newH = Math.max(1, Math.min(GRID_MAX_H, Math.round(startH + dy / (rowHpx + gap))));
el.dataset.gridW = newW;
el.dataset.gridH = newH;
applyGridSize(el);
}
async function onUp() {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
const mid = el.dataset.metricId;
const col = parseInt(el.dataset.gridCol) || 0;
const row = parseInt(el.dataset.gridRow) || 0;
const w = parseInt(el.dataset.gridW) || 1;
const h = parseInt(el.dataset.gridH) || 2;
await saveLayout(mid, col, row, w, h);
}
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
});
}
// ─── Drag-and-drop ────────────────────────────────────────────────────────────
function initDrag(grid) {
let dragged = null;
const items = () => [...grid.querySelectorAll(':scope > .t-metric')];
// Сдвиг элементов вправо/вниз, освобождая место перед target
function shiftBefore(target) {
if (!dragged || target === dragged) return;
const all = items();
const fromIdx = all.indexOf(dragged);
const toIdx = all.indexOf(target);
if (fromIdx === -1 || toIdx === -1) return;
if (fromIdx < toIdx) {
target.after(dragged);
} else {
target.before(dragged);
}
}
grid.addEventListener('dragstart', e => {
const metric = e.target.closest('.t-metric');
if (!metric || !grid.classList.contains('drag-mode')) { e.preventDefault(); return; }
dragged = metric;
metric.classList.add('drag-ghost');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', metric.dataset.metricId);
});
grid.addEventListener('dragend', e => {
const metric = e.target.closest('.t-metric');
if (!metric) return;
metric.classList.remove('drag-ghost');
grid.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
dragged = null;
// Сохранить новый порядок: обновляем grid_row / grid_col по позиции в DOM
persistOrder(grid);
});
grid.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('.t-metric');
if (!target || target === dragged) return;
grid.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
target.classList.add('drag-over');
shiftBefore(target);
});
grid.addEventListener('drop', e => {
e.preventDefault();
grid.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
});
}
// Пересчитать col/row по DOM-порядку и сохранить в БД одним батч-запросом
async function persistOrder(grid) {
let col = 0, row = 0, rowMaxH = 2;
const items = [];
grid.querySelectorAll(':scope > .t-metric').forEach(el => {
const w = parseInt(el.dataset.gridW) || 1;
const h = parseInt(el.dataset.gridH) || 2;
if (col + w > GRID_COLS) {
col = 0;
row += rowMaxH;
rowMaxH = 2;
}
el.dataset.gridCol = col;
el.dataset.gridRow = row;
rowMaxH = Math.max(rowMaxH, h);
col += w;
items.push({ id: el.dataset.metricId, grid_col: col - w, grid_row: row, grid_w: w, grid_h: h });
});
await apiFetch('/api/metrics/layout_batch', 'POST', { items });
}
// ─── Переход к трекеру на дашборде с подсветкой ───────────────────────────────
function initMetricLinks() {
document.querySelectorAll('.t-slot-metric-link').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const mid = link.dataset.metricId;
const target = document.getElementById(`metric-${mid}`);
if (!target) return;
// Закрыть слот если открыт
const detail = link.closest('.t-slot-detail');
if (detail) {
const slot = detail.previousElementSibling;
if (slot?.classList.contains('open')) {
slot.classList.remove('open');
detail.style.display = 'none';
}
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.remove('highlight');
// Перезапуск анимации через reflow
void target.offsetWidth;
target.classList.add('highlight');
target.addEventListener('animationend', () => target.classList.remove('highlight'), { once: true });
});
});
}
// ─── Кнопки включения режима редактирования сетки ────────────────────────────
function initGridEditButtons() {
document.querySelectorAll('.t-grid-edit-btn').forEach(btn => {
const gridId = btn.dataset.grid;
const grid = document.getElementById(gridId);
if (!grid) return;
btn.addEventListener('click', () => {
const active = grid.classList.toggle('drag-mode');
btn.classList.toggle('active', active);
// draggable выставляем/снимаем на всех метриках
grid.querySelectorAll(':scope > .t-metric').forEach(el => {
el.draggable = active;
});
});
});
}
// ─── Добавить resize-ручку к элементу ────────────────────────────────────────
function addResizeHandle(el) {
if (el.querySelector('.t-metric-resize')) return;
const h = document.createElement('div');
h.className = 't-metric-resize';
h.title = 'Изменить размер';
h.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M9 1L1 9M5 1L1 5M9 5L5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
el.appendChild(h);
}
// ─── Инициализация всех grid-контейнеров ─────────────────────────────────────
function initMetricsGrids() {
document.querySelectorAll('.t-metrics-grid').forEach(grid => {
grid.querySelectorAll(':scope > .t-metric').forEach(el => {
applyGridSize(el);
addResizeHandle(el);
initResize(el, grid);
});
initDrag(grid);
});
initGridEditButtons();
}
// После динамической вставки нового виджета — подключить его к сетке
function gridRegisterNew(el, grid) {
applyGridSize(el);
addResizeHandle(el);
initResize(el, grid);
if (grid.classList.contains('drag-mode')) el.draggable = true;
}
// ─── Инициализация ────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initMetricWidgets();
initAddMetric();
initMetricsGrids();
initMetricLinks();
});

20
app/static/js/schedule.js Normal file
View file

@ -0,0 +1,20 @@
// ═══ Переименование расписания ═══════════════════════════════════════════════
function initRenameSchedule() {
document.querySelector('.rename-schedule-btn')?.addEventListener('click', () => {
const current = document.querySelector('.schedule-name-display')?.textContent.trim() || '';
openRenameModal('Переименовать расписание', current, async (val) => {
const res = await apiFetch(`/schedules/${SCHEDULE_ID}/rename`, 'POST', { name: val });
if (res.ok) {
document.querySelector('.schedule-name-display').textContent = val;
document.title = val;
} else alert(res.error || 'Ошибка');
});
});
}
// ─── Инициализация страницы расписания ───────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initRenameModal();
initEditMode();
initRenameSchedule();
});

439
app/static/js/subjects.js Normal file
View file

@ -0,0 +1,439 @@
// ═══ Переименование предмета ══════════════════════════════════════════════════
function initSubjectRename() {
document.querySelectorAll('.rename-subject-btn').forEach(btn => {
btn.addEventListener('click', () => {
const card = btn.closest('[data-subject]');
const current = card?.querySelector('.subject-name-display')?.textContent.trim() || '';
openRenameModal('Переименовать предмет', current, async (val) => {
const res = await apiFetch(
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}/field`,
'POST', { field: 'name', value: val }
);
if (res.ok) location.reload(); else alert(res.error || 'Ошибка');
});
});
});
}
// ═══ Удаление предмета ═══════════════════════════════════════════════════════
function initDeleteSubject() {
document.querySelectorAll('.delete-subject-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(`Удалить «${btn.dataset.subject}» полностью?`)) return;
const res = await apiFetch(
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}`, 'DELETE');
if (res.ok) location.reload(); else alert(res.error || 'Ошибка');
});
});
}
// ═══ Inline-редактирование строк слотов ══════════════════════════════════════
function initSlotInlineEdit() {
document.querySelectorAll('.slot-inline-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const viewRow = btn.closest('.t-slot-row');
const editRow = viewRow?.nextElementSibling;
if (!editRow?.classList.contains('t-slot-edit-row')) return;
viewRow.classList.add('hidden');
editRow.classList.remove('hidden');
editRow.querySelector('.edit-day')?.focus();
});
});
document.querySelectorAll('.slot-cancel-btn').forEach(btn => {
btn.addEventListener('click', () => {
const editRow = btn.closest('.t-slot-edit-row');
const viewRow = editRow?.previousElementSibling;
editRow.classList.add('hidden');
viewRow?.classList.remove('hidden');
});
});
document.querySelectorAll('.slot-save-btn').forEach(btn => {
btn.addEventListener('click', () => saveSlotEdit(btn.closest('.t-slot-edit-row')));
});
document.querySelectorAll('.t-slot-edit-row input').forEach(input => {
input.addEventListener('keydown', e => {
if (e.key === 'Enter') saveSlotEdit(input.closest('.t-slot-edit-row'));
if (e.key === 'Escape') input.closest('.t-slot-edit-row').querySelector('.slot-cancel-btn').click();
});
});
}
async function saveSlotEdit(editRow) {
if (!editRow) return;
const d = editRow.dataset;
const newDay = editRow.querySelector('.edit-day').value.trim().toLowerCase();
const newTime = editRow.querySelector('.edit-time').value.trim();
const newRoom = editRow.querySelector('.edit-room').value.trim();
const newTeach = editRow.querySelector('.edit-teach').value.trim();
const res = await apiFetch(
`/schedules/${d.schedule}/subjects/${enc(d.subject)}/slot`,
'PATCH',
{ old_day: d.day, old_time: d.time, type_name: d.type, date_range: d.period,
day: newDay, time: newTime, classroom: newRoom, teacher: newTeach }
);
if (res.ok) {
// Обновляем ячейки строки просмотра без перезагрузки
const viewRow = editRow.previousElementSibling;
viewRow.querySelector('.slot-view-day').textContent = newDay ? newDay.charAt(0).toUpperCase() + newDay.slice(1) : '—';
viewRow.querySelector('.slot-view-time').textContent = newTime || '—';
viewRow.querySelector('.slot-view-room').textContent = newRoom || '—';
viewRow.querySelector('.slot-view-teach').textContent = newTeach || '—';
// Обновляем data-атрибуты обеих строк
for (const row of [viewRow, editRow]) {
row.dataset.day = newDay;
row.dataset.time = newTime;
}
// Обновляем data на кнопке удаления
const delBtn = viewRow.querySelector('.slot-delete-btn');
if (delBtn) { delBtn.dataset.day = newDay; delBtn.dataset.time = newTime; }
editRow.classList.add('hidden');
viewRow.classList.remove('hidden');
} else {
alert(res.error || 'Ошибка сохранения');
}
}
// ═══ Удаление слота ══════════════════════════════════════════════════════════
function initDeleteSlot() {
document.querySelectorAll('.slot-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Удалить эту строку?')) return;
const res = await apiFetch(
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}/slot`,
'DELETE',
{ day: btn.dataset.day, time: btn.dataset.time,
type_name: btn.dataset.type, date_range: btn.dataset.daterange }
);
if (res.ok) {
const viewRow = btn.closest('.t-slot-row');
const editRow = viewRow?.nextElementSibling;
viewRow?.remove();
if (editRow?.classList.contains('t-slot-edit-row')) editRow.remove();
} else {
alert(res.error || 'Ошибка');
}
});
});
}
// ═══ Добавление новой строки (слота) ════════════════════════════════════════
function initAddSlot() {
document.querySelectorAll('.add-slot-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const section = btn.closest('.t-subj-slots-section');
const tbody = section.querySelector('.slots-tbody');
const schedule = btn.dataset.schedule;
const subject = btn.dataset.subject;
const typeName = btn.dataset.type;
const period = btn.dataset.period;
// Вставляем временную строку редактирования
const tempId = 'newrow-' + Date.now();
const tr = document.createElement('tr');
tr.className = 't-slot-edit-row';
tr.dataset.day = '';
tr.dataset.time = '';
tr.dataset.type = typeName;
tr.dataset.period = period;
tr.dataset.subject = subject;
tr.dataset.schedule = schedule;
tr.id = tempId;
tr.innerHTML = `
<td><input type="text" class="t-input t-input-sm edit-day" placeholder="пн" aria-label="День"></td>
<td><input type="text" class="t-input t-input-sm edit-time" placeholder="09:00-10:30" aria-label="Время"></td>
<td><input type="text" class="t-input t-input-sm edit-room" placeholder="Кабинет" aria-label="Кабинет"></td>
<td><input type="text" class="t-input t-input-sm edit-teach" placeholder="Преподаватель" aria-label="Преподаватель"></td>
<td>
<div class="t-slot-edit-save-row">
<button type="button" class="t-btn-icon new-slot-save-btn" title="Сохранить">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button type="button" class="t-btn-icon new-slot-cancel-btn" title="Отмена">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</td>`;
tbody.appendChild(tr);
tr.querySelector('.edit-day').focus();
const doSave = async () => {
const day = tr.querySelector('.edit-day').value.trim().toLowerCase();
const time = tr.querySelector('.edit-time').value.trim();
const room = tr.querySelector('.edit-room').value.trim();
const teach = tr.querySelector('.edit-teach').value.trim();
if (!day) { alert('Укажите день'); return; }
const res = await apiFetch(
`/schedules/${schedule}/subjects/${enc(subject)}/slot`,
'PUT',
{ type_name: typeName, date_range: period, day, time, classroom: room, teacher: teach }
);
if (res.ok) location.reload();
else alert(res.error || 'Ошибка');
};
tr.querySelector('.new-slot-save-btn').addEventListener('click', doSave);
tr.querySelector('.new-slot-cancel-btn').addEventListener('click', () => tr.remove());
tr.querySelectorAll('input').forEach(inp => {
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') doSave();
if (e.key === 'Escape') tr.remove();
});
});
});
});
}
// ═══ Редактирование периода (date_range) ═════════════════════════════════════
function initEditPeriod() {
document.querySelectorAll('.edit-period-btn').forEach(btn => {
const section = btn.closest('.t-subj-slots-section');
const form = section.querySelector('.t-subj-period-form');
const display = section.querySelector('.t-subj-period-display');
btn.addEventListener('click', () => {
form.classList.toggle('hidden');
if (!form.classList.contains('hidden')) {
form.querySelector('.period-input').select();
}
});
const doSave = async () => {
const newPeriod = form.querySelector('.period-input').value.trim();
if (!newPeriod) return;
const d = section.dataset;
const res = await apiFetch(
`/schedules/${d.schedule}/subjects/${enc(d.subject)}/period`,
'POST',
{ type_name: d.type, old_period: d.period, new_period: newPeriod }
);
if (res.ok) {
display.textContent = newPeriod;
section.dataset.period = newPeriod;
// Обновляем data-period на всех строках в секции
section.querySelectorAll('[data-period]').forEach(el => el.dataset.period = newPeriod);
section.querySelectorAll('.add-slot-btn').forEach(el => el.dataset.period = newPeriod);
form.classList.add('hidden');
} else {
alert(res.error || 'Ошибка');
}
};
section.querySelector('.save-period-btn')?.addEventListener('click', doSave);
form.querySelector('.period-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter') doSave();
if (e.key === 'Escape') form.classList.add('hidden');
});
});
}
// ═══ Описание предмета ════════════════════════════════════════════════════════
function initDescriptions() {
document.querySelectorAll('.subject-description').forEach(block => {
const { schedule: sid, subject } = block.dataset;
const editBtn = block.querySelector('.edit-desc-btn');
const form = block.querySelector('.edit-desc-form');
const textEl = block.querySelector('.description-text');
const input = block.querySelector('.desc-input');
const saveBtn = block.querySelector('.save-desc-btn');
const cancelBtn = block.querySelector('.cancel-desc-btn');
if (!editBtn) return;
editBtn.addEventListener('click', () => {
form.classList.add('open');
editBtn.hidden = true;
});
cancelBtn.addEventListener('click', () => {
form.classList.remove('open');
editBtn.hidden = false;
});
saveBtn.addEventListener('click', async () => {
const res = await apiFetch(
`/schedules/${sid}/subjects/${enc(subject)}/description`,
'POST', { description: input.value }
);
if (res.ok) {
textEl.textContent = input.value || 'Нет описания';
textEl.classList.toggle('text-muted', !input.value);
textEl.classList.toggle('t-desc-italic', !input.value);
form.classList.remove('open');
editBtn.hidden = false;
}
});
});
}
// ═══ Скрыть / показать предмет в расписании ══════════════════════════════════
function initToggleVisibility() {
document.querySelectorAll('.toggle-visibility-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const res = await apiFetch(
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}/visibility`,
'POST'
);
if (!res.ok) { alert(res.error || 'Ошибка'); return; }
const hidden = res.hidden;
btn.dataset.hidden = hidden ? 'true' : 'false';
btn.classList.toggle('subject-hidden', hidden);
btn.title = hidden ? 'Показать в расписании' : 'Скрыть из расписания';
btn.innerHTML = hidden
? `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`
: `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`;
// Visual indicator on the card
btn.closest('.t-subj-card')?.classList.toggle('subject-hidden', hidden);
});
});
}
// ═══ Переименование трекера ═══════════════════════════════════════════════════
function initRenameMetric() {
const backdrop = document.getElementById('renameMetricModalBackdrop');
const input = document.getElementById('rename-metric-input');
const confirmBtn = document.getElementById('rename-metric-confirm-btn');
if (!backdrop || !input || !confirmBtn) return;
let _metricId = null;
let _titleEl = null;
document.querySelectorAll('.t-metric-rename-btn').forEach(btn => {
btn.addEventListener('click', () => {
const widget = btn.closest('.t-metric');
_metricId = widget?.dataset.metricId;
_titleEl = widget?.querySelector('.t-metric-title');
input.value = _titleEl?.textContent.trim() || '';
openModal('renameMetricModalBackdrop');
setTimeout(() => { input.select(); }, 50);
});
});
let _saving = false;
const doSave = async () => {
const label = input.value.trim();
if (!label || !_metricId || _saving) return;
_saving = true;
confirmBtn.disabled = true;
const res = await apiFetch(`/api/metrics/${_metricId}/rename`, 'POST', { label });
_saving = false;
confirmBtn.disabled = false;
if (res.ok) {
if (_titleEl) _titleEl.textContent = label;
closeModal('renameMetricModalBackdrop');
} else {
alert(res.error || 'Ошибка');
}
};
confirmBtn.addEventListener('click', doSave);
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); doSave(); } });
}
// ═══ Тип сдачи предмета ═══════════════════════════════════════════════════════
function initCompletionType() {
document.querySelectorAll('.completion-type-select').forEach(sel => {
sel.addEventListener('change', async () => {
const res = await apiFetch(
`/schedules/${sel.dataset.schedule}/subjects/${enc(sel.dataset.subject)}/completion_type`,
'POST', { completion_type: sel.value || null }
);
if (!res.ok) alert(res.error || 'Ошибка');
});
});
}
// ═══ Завершить / возобновить предмет ═════════════════════════════════════════
function initToggleCompleted() {
document.querySelectorAll('.toggle-completed-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const res = await apiFetch(
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}/completed`,
'POST'
);
if (!res.ok) { alert(res.error || 'Ошибка'); return; }
const done = res.completed;
btn.classList.toggle('is-completed', done);
btn.title = done ? 'Возобновить предмет' : 'Отметить как завершённый';
btn.innerHTML = done
? `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`
: `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="20 6 9 17 4 12" opacity=".3"/></svg>`;
const card = btn.closest('.t-subj-card');
card?.classList.toggle('subject-completed', done);
// Обновляем бейдж
const head = card?.querySelector('.t-subj-head-left');
let badge = head?.querySelector('.t-completed-badge');
if (done && !badge) {
badge = document.createElement('span');
badge.className = 't-completed-badge';
badge.textContent = 'Завершён';
head?.appendChild(badge);
} else if (!done && badge) {
badge.remove();
}
});
});
}
// ═══ Ссылка на СДО ════════════════════════════════════════════════════════════
function initSdoUrl() {
document.querySelectorAll('.t-subj-meta-sdo').forEach(block => {
const section = block.closest('.t-subj-meta-section');
if (!section) return;
const { schedule, subject } = section.dataset;
const saveBtn = block.querySelector('.save-sdo-btn');
const input = block.querySelector('.sdo-url-input');
if (!saveBtn || !input) return;
saveBtn.addEventListener('click', async () => {
const res = await apiFetch(
`/schedules/${schedule}/subjects/${enc(subject)}/sdo_url`,
'POST', { sdo_url: input.value.trim() }
);
if (!res.ok) { alert(res.error || 'Ошибка'); return; }
// Перезагружаем страницу, чтобы обновить кнопку открытия СДО
location.reload();
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter') saveBtn.click();
});
});
}
// ═══ Промт AI-трекера (клик по названию) ══════════════════════════════════════
function initMetricAiPromptView() {
document.querySelectorAll('.t-metric-title-clickable').forEach(title => {
title.addEventListener('click', async () => {
const widget = title.closest('.t-metric');
const id = widget?.dataset.metricId;
if (!id) return;
const res = await apiFetch(`/api/metrics/${id}/ai_prompt`, 'GET');
if (res.ok) {
document.getElementById('ai-prompt-text').textContent = res.prompt || '—';
openModal('aiPromptModalBackdrop');
} else {
alert(res.error || 'Ошибка');
}
});
});
}
// ─── Инициализация страницы предметов ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initRenameModal();
initEditMode();
initSubjectRename();
initDeleteSubject();
initSlotInlineEdit();
initDeleteSlot();
initAddSlot();
initEditPeriod();
initDescriptions();
initToggleVisibility();
initRenameMetric();
initCompletionType();
initToggleCompleted();
initSdoUrl();
initMetricAiPromptView();
});

33
app/static/js/upload.js Normal file
View file

@ -0,0 +1,33 @@
// ═══ Предпросмотр изображения на странице загрузки ═══════════════════════════
document.addEventListener('DOMContentLoaded', () => {
const drop = document.getElementById('file-drop-area');
const input = document.getElementById('file-input');
const preview = document.getElementById('file-preview');
const changeBtn = document.getElementById('change-file-btn');
if (!drop || !input) return;
drop.addEventListener('click', () => input.click());
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
if (preview) {
preview.src = e.target.result;
preview.hidden = false;
}
if (changeBtn) changeBtn.hidden = false;
drop.querySelector('.t-upload-drop-text')?.style.setProperty('display', 'none');
};
reader.readAsDataURL(file);
});
changeBtn?.addEventListener('click', e => {
e.stopPropagation();
input.value = '';
if (preview) preview.hidden = true;
changeBtn.hidden = true;
drop.querySelector('.t-upload-drop-text')?.style.removeProperty('display');
});
});

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Вход{% endblock %}
{% block content %}
<div class="t-auth-wrap">
<div class="t-auth-card">
<div class="t-auth-title">Вход в tablo</div>
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="t-field">
<label class="t-label" for="login">Логин</label>
<input type="text" name="login" id="login" class="t-input" required autofocus
autocomplete="username" placeholder="Ваш логин">
</div>
<div class="t-field">
<label class="t-label" for="password">Пароль</label>
<input type="password" name="password" id="password" class="t-input" required
autocomplete="current-password" placeholder="••••••••">
</div>
<div class="t-field">
<label class="t-form-check" for="remember">
<input type="checkbox" name="remember" id="remember">
Запомнить меня
</label>
</div>
<button type="submit" class="t-btn t-btn-primary t-btn-full">Войти</button>
</form>
<hr class="t-divider">
<div class="t-auth-footer">
Нет аккаунта? <a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Регистрация{% endblock %}
{% block content %}
<div class="t-auth-wrap">
<div class="t-auth-card">
<div class="t-auth-title">Регистрация</div>
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="t-field">
<label class="t-label" for="login">Логин</label>
<input type="text" name="login" id="login" class="t-input" required autofocus
minlength="3" autocomplete="username" placeholder="Минимум 3 символа">
<div class="t-hint">Минимум 3 символа, уникальный</div>
</div>
<div class="t-field">
<label class="t-label" for="password">Пароль</label>
<input type="password" name="password" id="password" class="t-input" required
minlength="6" autocomplete="new-password" placeholder="Минимум 6 символов">
</div>
<div class="t-field">
<label class="t-label" for="confirm">Повторите пароль</label>
<input type="password" name="confirm" id="confirm" class="t-input" required
autocomplete="new-password" placeholder="••••••••">
</div>
<button type="submit" class="t-btn t-btn-primary t-btn-full">Создать аккаунт</button>
</form>
<hr class="t-divider">
<div class="t-auth-footer">
Уже есть аккаунт? <a href="{{ url_for('auth.login') }}">Войти</a>
</div>
</div>
</div>
{% endblock %}

103
app/templates/base.html Normal file
View file

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<title>{% block title %}Расписание{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/schedule.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/subjects.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_checkpoints.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_progress_bar.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_counter.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_stages.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_checklist.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_attendance.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_grades.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_deadlines.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_streak.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_effort_hours.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_rating_history.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/forms.css') }}">
<script>document.documentElement.dataset.theme=localStorage.getItem('tablo-theme')||'dark';</script>
</head>
<body>
<nav class="t-nav">
<a class="t-brand" href="{{ url_for('schedules.profile') }}">
<span class="t-brand-tab">tab</span><span class="t-brand-lo">lo</span>
</a>
{% if current_user.is_authenticated %}
<span class="t-nav-user">{{ current_user.login }}</span>
{% endif %}
<div class="t-nav-r">
{% block nav_extra %}{% endblock %}
<button type="button" class="t-btn-icon t-theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему">
<svg class="t-icon-sun" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="t-icon-moon" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
{% if current_user.is_authenticated %}
<a class="t-btn t-btn-outline" href="{{ url_for('schedules.upload') }}">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Новое
</a>
<a class="t-btn t-btn-ghost" href="{{ url_for('auth.logout') }}">Выйти</a>
{% else %}
<a class="t-btn t-btn-outline" href="{{ url_for('auth.login') }}">Войти</a>
<a class="t-btn t-btn-primary" href="{{ url_for('auth.register') }}">Регистрация</a>
{% endif %}
</div>
</nav>
<div class="t-page">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="t-alert t-alert-{{ category }}">
{{ message }}
<button type="button" class="t-alert-close" onclick="this.parentElement.remove()">×</button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<footer class="t-footer">
<a href="https://tablo.deev.su" class="t-footer-brand">
<span class="t-brand-tab">tab</span><span class="t-brand-lo">lo</span>
</a>
<span class="t-footer-sep">·</span>
<a href="https://tablo.deev.su" class="t-footer-link">tablo.deev.su</a>
<span class="t-footer-sep">·</span>
<span>Project by Deev Egor © 2026</span>
</footer>
<meta name="csrf-token" content="{{ csrf_token() }}">
<script src="{{ url_for('static', filename='js/core.js') }}"></script>
<script>
(function () {
const saved = localStorage.getItem('tablo-theme') || 'dark';
document.documentElement.dataset.theme = saved;
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('theme-toggle');
if (!btn) return;
function applyTheme(t) {
document.documentElement.dataset.theme = t;
localStorage.setItem('tablo-theme', t);
}
btn.addEventListener('click', () => {
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
});
});
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,386 @@
{% macro render_metric(metric, can_edit=False, show_rename=False) %}
{% set prog = metric.progress or {} %}
{% set cfg = metric.config or {} %}
<div class="t-metric t-metric--{{ metric.type }}"
id="metric-{{ metric.id }}"
data-metric-id="{{ metric.id }}"
data-metric-type="{{ metric.type }}"
data-progress='{{ prog | tojson }}'
data-config='{{ cfg | tojson }}'
data-grid-col="{{ metric.grid_col }}"
data-grid-row="{{ metric.grid_row }}"
data-grid-w="{{ metric.grid_w }}"
data-grid-h="{{ metric.grid_h }}">
{# ── Заголовок ── #}
<div class="t-metric-header">
{% if metric.ai_prompt %}
<span class="t-metric-title t-metric-title-clickable" title="Нажмите, чтобы увидеть промт">{{ metric.label }}</span>
{% else %}
<span class="t-metric-title">{{ metric.label }}</span>
{% endif %}
{% if can_edit and show_rename %}
<button type="button" class="t-metric-rename-btn" title="Переименовать трекер" aria-label="Переименовать трекер">
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
{% endif %}
</div>
{# ── Кнопка удаления (абсолютная, правый верхний угол) ── #}
{% if can_edit %}
<button type="button" class="delete-metric-btn" title="Удалить" aria-label="Удалить трекер">
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
{% endif %}
<div class="t-metric-body">
{# ════════════════════════════════════════════════════════════════════════
CHECKPOINTS — контрольные точки
════════════════════════════════════════════════════════════════════════ #}
{% if metric.type == 'checkpoints' %}
{% set total = cfg.get('total', 0) | int %}
{% set checked = prog.get('checked', []) %}
{% set done = checked | length %}
{% set pct = (done / total * 100) | int if total else 0 %}
<div class="t-cp-bar-row">
<span class="t-cp-fraction">{{ done }}<span class="t-cp-fraction-max">/{{ total }}</span></span>
<div class="t-cp-track"><div class="t-cp-fill" style="width:{{ pct }}%"></div></div>
</div>
<div class="t-cps">
{% for i in range(total) %}
{% set lbl = cfg.get('labels', [])[i] if cfg.get('labels') and i < (cfg.get('labels') | length) else (i + 1) | string %}
{% if can_edit %}
<button type="button" class="t-cp {{ 'done' if i in checked else '' }}"
data-index="{{ i }}" title="{{ lbl }}" aria-label="Точка {{ lbl }}">{{ lbl }}</button>
{% else %}
<span class="t-cp {{ 'done' if i in checked else '' }}" title="{{ lbl }}">{{ lbl }}</span>
{% endif %}
{% endfor %}
{% if not total %}<span class="t-metric-empty">Не настроено</span>{% endif %}
</div>
{# ════════════════════════════════════════════════════════════════════════
PROGRESS BAR — накопление баллов
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'progress_bar' %}
{% set cur = prog.get('current', 0) %}
{% set mx = cfg.get('max', 100) | float %}
{% set pct = [[cur / mx * 100, 0] | max, 100] | min | int if mx else 0 %}
<div class="t-pb-wrap">
<div class="t-pb-ring-wrap">
<svg class="t-pb-ring" viewBox="0 0 80 80">
<circle class="t-pb-ring-bg" cx="40" cy="40" r="34"/>
<circle class="t-pb-ring-fill" cx="40" cy="40" r="34"
stroke-dasharray="213.6"
stroke-dashoffset="{{ ((100 - pct) / 100 * 213.6) | round(1) }}"/>
</svg>
<span class="t-pb-pct">{{ pct }}%</span>
</div>
<div class="t-pb-right">
<div class="t-pb-val">{{ cur }}<span class="t-pb-val-max"> / {{ mx | int }}{% if cfg.get('unit') %} {{ cfg.get('unit') }}{% endif %}</span></div>
{% if can_edit %}
<div class="t-pb-input-row">
<input type="number" class="t-input t-input-sm progress-input"
value="{{ cur }}" min="0" max="{{ mx }}" step="{{ cfg.get('step', 1) }}"
aria-label="Текущее значение">
<button type="button" class="t-btn t-btn-primary t-btn-sm update-progress-btn" aria-label="Сохранить">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
</button>
</div>
{% endif %}
</div>
</div>
{# ════════════════════════════════════════════════════════════════════════
COUNTER — счётчик
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'counter' %}
{% set cur = prog.get('current', 0) %}
{% set mx = cfg.get('max') | int if cfg.get('max') else 0 %}
<div class="t-counter-body">
<div class="t-counter-main">
{% if can_edit %}<button type="button" class="t-counter-btn counter-dec" aria-label="Уменьшить">−</button>{% endif %}
<div class="t-counter-display">
<span class="t-counter-val">{{ cur }}</span>
<span class="t-counter-sub">
{%- if mx %}/ {{ mx }}{% endif %}
{%- if cfg.get('unit') %} {{ cfg.get('unit') }}{% endif -%}
</span>
</div>
{% if can_edit %}<button type="button" class="t-counter-btn counter-inc" aria-label="Увеличить">+</button>{% endif %}
</div>
{% if mx %}
<div class="t-counter-bar-row">
<div class="t-counter-bar-fill" style="width:{{ [[cur / mx * 100, 0] | max, 100] | min | int }}%"></div>
</div>
{% endif %}
</div>
{# ════════════════════════════════════════════════════════════════════════
STAGES — этапы
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'stages' %}
{% set cur_stage = prog.get('current_stage', 0) %}
{% set stages = cfg.get('stages', []) %}
<div class="t-stages">
{% for stage in stages %}
{% set idx = loop.index0 %}
{% set state = 'done' if idx < cur_stage else ('active' if idx == cur_stage else '') %}
<div class="t-stage-item">
{% if can_edit %}
<button type="button" class="t-stage-btn {{ state }} stage-btn" data-index="{{ idx }}" title="{{ stage }}">
{% else %}
<div class="t-stage-btn {{ state }}">
{% endif %}
<span class="t-stage-dot">
{% if idx < cur_stage %}
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
{% else %}{{ loop.index }}{% endif %}
</span>
<span class="t-stage-lbl">{{ stage }}</span>
{% if can_edit %}</button>{% else %}</div>{% endif %}
{% if not loop.last %}
<div class="t-stage-conn {{ 'done' if idx < cur_stage else '' }}"></div>
{% endif %}
</div>
{% else %}
<span class="t-metric-empty">Не настроено</span>
{% endfor %}
</div>
{# ════════════════════════════════════════════════════════════════════════
CHECKLIST — чеклист
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'checklist' %}
{% set checked = prog.get('checked', []) %}
{% set items = cfg.get('items', []) %}
{% set pct = (checked | length / items | length * 100) | int if items else 0 %}
<div class="t-cl-bar-row">
<div class="t-cl-track"><div class="t-cl-fill" style="width:{{ pct }}%"></div></div>
<span class="t-cl-frac">{{ checked | length }}/{{ items | length }}</span>
</div>
<div class="t-cl-list">
{% for item in items %}
{% set idx = loop.index0 %}
<label class="t-cl-item {{ 'checked' if idx in checked else '' }}" for="cl-{{ metric.id }}-{{ idx }}">
<input type="checkbox" class="checklist-item" data-index="{{ idx }}"
{{ 'checked' if idx in checked else '' }}
{{ 'disabled' if not can_edit else '' }}
id="cl-{{ metric.id }}-{{ idx }}" aria-label="{{ item }}">
<span class="t-cl-box">
<svg width="8" height="8" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
</span>
<span class="t-cl-text">{{ item }}</span>
</label>
{% else %}
<span class="t-metric-empty">Нет пунктов</span>
{% endfor %}
</div>
{# ════════════════════════════════════════════════════════════════════════
ATTENDANCE — посещаемость
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'attendance' %}
{% set records = prog.get('records', []) %}
{% set present = records | selectattr('status', 'equalto', 'present') | list | length %}
{% set absent = records | selectattr('status', 'equalto', 'absent') | list | length %}
{% set excused = records | selectattr('status', 'equalto', 'excused') | list | length %}
<div class="t-att-stats">
<div class="t-att-stat t-att-stat--present">
<span class="t-att-num">{{ present }}</span>
<span class="t-att-lbl">Присут.</span>
</div>
<div class="t-att-stat t-att-stat--absent">
<span class="t-att-num">{{ absent }}</span>
<span class="t-att-lbl">Пропуск</span>
</div>
<div class="t-att-stat t-att-stat--excused">
<span class="t-att-num">{{ excused }}</span>
<span class="t-att-lbl">Уваж.</span>
</div>
</div>
<div class="t-att-dots">
{% for rec in records[-40:] %}
<span class="t-att-dot t-att-dot--{{ rec.status }}" title="{{ rec.date }}"></span>
{% endfor %}
</div>
{% if can_edit %}
<div class="t-att-form">
<input type="date" class="t-input t-input-sm att-date" aria-label="Дата">
<select class="t-select t-select-sm att-status" aria-label="Статус">
<option value="present">Присут.</option>
<option value="absent">Пропуск</option>
<option value="excused">Уваж.</option>
</select>
<button type="button" class="t-btn t-btn-primary t-btn-sm add-att-btn" aria-label="Добавить">+</button>
</div>
{% endif %}
{# ════════════════════════════════════════════════════════════════════════
GRADES — оценки
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'grades' %}
{% set grades = prog.get('grades', {}) %}
{% set works = cfg.get('works', []) %}
{% set max_gr = cfg.get('max_grade', 5) | float %}
<div class="t-gr-list">
{% for work in works %}
{% set val = grades.get(work.name, '') %}
{% set pct = (val | float / max_gr * 100) | int if val != '' else 0 %}
<div class="t-gr-row">
<span class="t-gr-name" title="{{ work.name }}">{{ work.name }}</span>
<div class="t-gr-track"><div class="t-gr-fill" style="width:{{ pct }}%"></div></div>
<input type="number" class="t-input t-input-sm t-gr-input grade-input"
data-work="{{ work.name }}" value="{{ val }}"
min="0" max="{{ max_gr }}" aria-label="Оценка за {{ work.name }}"
{{ 'disabled' if not can_edit else '' }}>
</div>
{% else %}
<span class="t-metric-empty">Нет работ</span>
{% endfor %}
</div>
{% if works and can_edit %}
<button type="button" class="t-btn t-btn-primary t-btn-sm t-gr-save save-grades-btn">Сохранить</button>
{% endif %}
{# ════════════════════════════════════════════════════════════════════════
DEADLINES — дедлайны
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'deadlines' %}
{% set completed = prog.get('completed', []) %}
{% set items = cfg.get('items', []) %}
{% set pct = (completed | length / items | length * 100) | int if items else 0 %}
<div class="t-dl-bar-row">
<div class="t-dl-track"><div class="t-dl-fill" style="width:{{ pct }}%"></div></div>
<span class="t-dl-frac">{{ completed | length }}/{{ items | length }}</span>
</div>
<div class="t-dl-list">
{% for item in items %}
{% set idx = loop.index0 %}
<label class="t-dl-item {{ 'done' if idx in completed else '' }}" for="dl-{{ metric.id }}-{{ idx }}">
<input type="checkbox" class="deadline-item" data-index="{{ idx }}"
{{ 'checked' if idx in completed else '' }}
{{ 'disabled' if not can_edit else '' }}
id="dl-{{ metric.id }}-{{ idx }}" aria-label="{{ item.name }}">
<span class="t-dl-box">
<svg width="8" height="8" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
</span>
<span class="t-dl-name">{{ item.name }}</span>
{% if item.get('date') %}<span class="t-dl-date">{{ item.date }}</span>{% endif %}
</label>
{% else %}
<span class="t-metric-empty">Нет дедлайнов</span>
{% endfor %}
</div>
{# ════════════════════════════════════════════════════════════════════════
STREAK — серия
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'streak' %}
{% set count = prog.get('records', []) | length %}
<div class="t-streak-body">
<div class="t-streak-flame">🔥</div>
<div class="t-streak-num">{{ count }}</div>
<div class="t-streak-lbl">{{ 'день' if count % 10 == 1 and count % 100 != 11 else ('дня' if count % 10 in [2,3,4] and count % 100 not in [12,13,14] else 'дней') }} подряд</div>
</div>
{% if can_edit %}
<button type="button" class="t-btn t-btn-outline t-btn-sm t-streak-btn mark-today-btn">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
Отметить сегодня
</button>
{% endif %}
{# ════════════════════════════════════════════════════════════════════════
EFFORT HOURS — трудозатраты
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'effort_hours' %}
{% set total = prog.get('hours', 0) %}
{% set goal = cfg.get('goal_hours', 0) | float %}
{% set pct = [[total / goal * 100, 0] | max, 100] | min | int if goal else 0 %}
<div class="t-ef-wrap">
<div class="t-ef-left">
<div class="t-ef-num">{{ total }}<span class="t-ef-unit">ч</span></div>
{% if goal %}<div class="t-ef-goal">цель: {{ goal | int }}ч</div>{% endif %}
</div>
<div class="t-ef-right">
{% if goal %}
<div class="t-ef-bar-wrap">
<div class="t-ef-bar-fill" style="width:{{ pct }}%"></div>
<span class="t-ef-bar-pct">{{ pct }}%</span>
</div>
{% endif %}
{% if can_edit %}
<div class="t-ef-input-row">
<input type="number" class="t-input t-input-sm effort-input"
placeholder="+ч" min="0.5" step="0.5" aria-label="Добавить часы">
<button type="button" class="t-btn t-btn-primary t-btn-sm add-effort-btn">+</button>
</div>
{% endif %}
</div>
</div>
{# ════════════════════════════════════════════════════════════════════════
RATING HISTORY — история оценок
════════════════════════════════════════════════════════════════════════ #}
{% elif metric.type == 'rating_history' %}
{% set entries = prog.get('entries', []) %}
{% set scale = cfg.get('scale', 5) | float %}
{% if entries %}
{% set last = entries[-1] %}
{% set avg = (entries | sum(attribute='grade') / entries | length) | round(1) %}
{% endif %}
{# gh-1: только большая оценка — центральный вид #}
<div class="t-rh-center">
{% if entries %}
<div class="t-rh-big-val">{{ last.grade }}</div>
<div class="t-rh-big-scale">/ {{ scale | int }}</div>
<div class="t-rh-big-avg">ср. {{ avg }}</div>
{% else %}
<span class="t-metric-empty">Нет записей</span>
{% endif %}
</div>
{# gh-2+: разделённый вид #}
<div class="t-rh-wrap">
<div class="t-rh-left">
{% if entries %}
<div class="t-rh-last-val">{{ last.grade }}</div>
<div class="t-rh-last-scale">/ {{ scale | int }}</div>
<div class="t-rh-avg-lbl">ср.</div>
<div class="t-rh-avg">{{ avg }}</div>
{% else %}
<span class="t-metric-empty" style="font-size:9px">—</span>
{% endif %}
</div>
<div class="t-rh-right">
<div class="t-rh-list">
{% for entry in entries | reverse %}
{% set pct = (entry.grade / scale * 100) | int %}
<div class="t-rh-row">
<span class="t-rh-date">{{ entry.date }}</span>
<div class="t-rh-track"><div class="t-rh-fill" style="width:{{ pct }}%"></div></div>
<span class="t-rh-val">{{ entry.grade }}</span>
</div>
{% else %}
<span class="t-metric-empty">Нет записей</span>
{% endfor %}
</div>
{% if can_edit %}
<div class="t-rh-form">
<input type="date" class="t-input t-input-sm rating-date" aria-label="Дата">
<input type="number" class="t-input t-input-sm rating-grade"
placeholder="Оц." min="1" max="{{ scale | int }}" aria-label="Оценка">
<button type="button" class="t-btn t-btn-primary t-btn-sm add-rating-btn" aria-label="+">+</button>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>{# /t-metric-body #}
</div>
{% endmacro %}

View file

@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}Мои расписания{% endblock %}
{% block content %}
<div class="t-ph">
<h1 class="t-ph-title">Мои расписания</h1>
<div class="t-ph-right">
<a class="t-btn t-btn-primary" href="{{ url_for('schedules.upload') }}">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Добавить
</a>
</div>
</div>
{% if schedules %}
<div class="t-sched-grid" id="sched-grid">
{% for s in schedules %}
<div class="t-sched-card">
<div class="t-sched-card-title">{{ s.name }}</div>
<div class="t-sched-card-meta">
<span>
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{{ s.created_at.strftime('%d.%m.%Y') }}
</span>
<span>
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
{{ s.data | length }} предметов
</span>
</div>
<div class="t-sched-card-actions">
<a class="t-btn t-btn-primary t-btn-sm" href="{{ url_for('schedules.view', schedule_id=s.id) }}">Открыть</a>
<button type="button" class="t-btn t-btn-outline t-btn-sm share-btn" data-schedule-id="{{ s.id }}" title="Поделиться расписанием">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button>
<form action="{{ url_for('schedules.delete', schedule_id=s.id) }}" method="POST"
onsubmit="return confirm('Удалить расписание «{{ s.name }}»?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="t-btn t-btn-danger t-btn-sm" title="Удалить расписание">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</form>
</div>
<div class="t-sched-card-footer">
<label class="t-form-check" for="merge-{{ s.id }}">
<input type="checkbox" class="merge-check" value="{{ s.id }}" id="merge-{{ s.id }}">
Выбрать для объединения
</label>
</div>
</div>
{% endfor %}
</div>
{# Панель объединения #}
<div class="t-merge-panel" id="merge-panel" hidden>
<div class="t-label t-merge-title">Объединить выбранные расписания</div>
<form action="{{ url_for('schedules.merge') }}" method="POST" id="merge-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="merge-ids"></div>
<div class="t-input-group">
<input type="text" name="name" class="t-input" placeholder="Название нового расписания" required
aria-label="Название нового расписания">
<button type="submit" class="t-btn t-btn-primary t-btn-group-end">Объединить</button>
</div>
</form>
</div>
{% else %}
<div class="t-empty">
<div class="t-empty-icon">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="9" y1="15" x2="9.01" y2="15" stroke-width="2"/><line x1="12" y1="15" x2="12.01" y2="15" stroke-width="2"/><line x1="15" y1="15" x2="15.01" y2="15" stroke-width="2"/></svg>
</div>
<p>Расписаний пока нет</p>
<a class="t-btn t-btn-primary" href="{{ url_for('schedules.upload') }}">Добавить первое</a>
</div>
{% endif %}
{# Модальное окно шаринга #}
<div class="t-modal-backdrop" id="shareModalBackdrop" role="dialog" aria-modal="true" aria-label="Поделиться расписанием">
<div class="t-modal">
<div class="t-modal-header">
<span class="t-modal-title">Поделиться расписанием</span>
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('shareModalBackdrop')" title="Закрыть">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="t-modal-body">
<div class="t-field">
<label class="t-label" for="link-view">Ссылка для просмотра</label>
<div class="t-input-group">
<input type="text" class="t-input" id="link-view" readonly aria-label="Ссылка для просмотра">
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-view" title="Скопировать">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
<div class="t-hint">Другие могут смотреть, но не редактировать</div>
</div>
<div class="t-field">
<label class="t-label" for="link-template">Ссылка-шаблон</label>
<div class="t-input-group">
<input type="text" class="t-input" id="link-template" readonly aria-label="Ссылка-шаблон">
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-template" title="Скопировать">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
<div class="t-hint">Пользователь получит копию расписания</div>
</div>
<hr class="t-divider">
<div class="t-field">
<label class="t-label" for="editor-login">Добавить редактора</label>
<div class="t-input-group">
<input type="text" class="t-input" id="editor-login" placeholder="Логин пользователя" aria-label="Логин редактора">
<button type="button" class="t-btn t-btn-primary t-btn-group-end" id="add-editor-btn" title="Добавить редактора">Добавить</button>
</div>
<div class="t-hint">Редактор сможет изменять описания и трекеры</div>
</div>
<div id="editor-link-wrap" hidden class="t-field">
<label class="t-label" for="link-edit">Ссылка для редактора</label>
<div class="t-input-group">
<input type="text" class="t-input" id="link-edit" readonly aria-label="Ссылка для редактора">
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-edit" title="Скопировать">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.querySelectorAll('.t-modal-backdrop').forEach(bd => {
bd.addEventListener('click', e => { if (e.target === bd) bd.classList.remove('open'); });
});
let currentScheduleId = null;
document.querySelectorAll('.share-btn').forEach(btn => {
btn.addEventListener('click', async () => {
currentScheduleId = btn.dataset.scheduleId;
for (const type of ['view', 'template']) {
const res = await apiFetch(`/schedules/${currentScheduleId}/share`, 'POST', { type });
if (res.ok)
document.getElementById(`link-${type}`).value = `${location.origin}/shared/${res.token}`;
}
document.getElementById('shareModalBackdrop').classList.add('open');
});
});
document.getElementById('add-editor-btn').addEventListener('click', async () => {
const login = document.getElementById('editor-login').value.trim();
if (!login) return;
const res = await apiFetch(`/schedules/${currentScheduleId}/share/editors`, 'POST', { login });
if (res.ok) {
document.getElementById('link-edit').value = `${location.origin}/shared/${res.token}`;
document.getElementById('editor-link-wrap').hidden = false;
} else {
alert(res.error || 'Ошибка');
}
});
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
navigator.clipboard.writeText(document.getElementById(btn.dataset.target).value);
const orig = btn.innerHTML;
btn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
setTimeout(() => btn.innerHTML = orig, 1500);
});
});
/* Объединение расписаний */
const mergePanel = document.getElementById('merge-panel');
const mergeIds = document.getElementById('merge-ids');
document.querySelectorAll('.merge-check').forEach(cb => {
cb.addEventListener('change', () => {
const checked = document.querySelectorAll('.merge-check:checked');
mergePanel.hidden = checked.length < 2;
mergeIds.innerHTML = [...checked]
.map(c => `<input type="hidden" name="schedule_ids" value="${c.value}">`)
.join('');
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,429 @@
{% extends "base.html" %}
{% block title %}Предметы — {{ schedule.name }}{% endblock %}
{% block nav_extra %}
<a class="t-btn t-btn-ghost" href="{{ url_for('schedules.view', schedule_id=schedule.id) }}">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Расписание
</a>
{% if can_edit %}
<button type="button" class="t-btn t-btn-outline t-edit-btn" id="edit-mode-btn" title="Режим редактирования">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Редактировать
</button>
{% endif %}
{% endblock %}
{% block content %}
{% from "macros/metrics.html" import render_metric %}
<div class="t-ph">
<div class="t-ph-name-row">
<h1 class="t-ph-title">Предметы</h1>
<span class="t-muted">{{ schedule.name }}</span>
</div>
</div>
{% if not schedule.data %}
<div class="t-empty">
<div class="t-empty-icon">📋</div>
<p>В расписании нет предметов</p>
</div>
{% else %}
<div class="t-subj-grid">
{% for item in schedule.data %}
{% set subj = item.subject %}
{% set cfg = configs.get(subj) %}
{% set pidx = loop.index0 %}
{% set is_completed = cfg and cfg.completed %}
<div class="t-subj-card {{ 'subject-hidden' if cfg and cfg.hidden else '' }} {{ 'subject-completed' if is_completed else '' }}" data-subject="{{ subj }}">
{# ── Шапка карточки ── #}
<div class="t-subj-head">
<div class="t-subj-head-left">
<div class="t-subj-type-dots">
{% for type_name, type_data in item.get('types', {}).items() %}
{% set color = type_data.get('color', 'gray') if type_data is mapping and 'color' in type_data else 'gray' %}
<span class="t-type t-type-{{ color }}">{{ type_name }}</span>
{% endfor %}
</div>
<span class="t-subj-name subject-name-display">{{ subj }}</span>
{% if cfg and cfg.completion_type %}
<span class="t-completion-badge t-completion-{{ cfg.completion_type | replace(' ', '_') | replace('.', '') | lower }}">{{ cfg.completion_type }}</span>
{% endif %}
{% if is_completed %}
<span class="t-completed-badge">Завершён</span>
{% endif %}
</div>
<div class="t-subj-head-actions {% if can_edit %}edit-ctrl{% endif %}">
{% if can_edit %}
<button type="button" class="t-btn-icon toggle-completed-btn {{ 'is-completed' if is_completed else '' }}"
title="{{ 'Возобновить предмет' if is_completed else 'Отметить как завершённый' }}"
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
{% if is_completed %}
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
{% else %}
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="20 6 9 17 4 12" opacity=".3"/></svg>
{% endif %}
</button>
<button type="button" class="t-btn-icon toggle-visibility-btn {{ 'subject-hidden' if cfg and cfg.hidden else '' }}"
title="{{ 'Показать в расписании' if cfg and cfg.hidden else 'Скрыть из расписания' }}"
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
data-hidden="{{ 'true' if cfg and cfg.hidden else 'false' }}">
{% if cfg and cfg.hidden %}
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{% else %}
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{% endif %}
</button>
<button type="button" class="t-btn-icon rename-subject-btn" title="Переименовать предмет"
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button type="button" class="t-btn-icon danger delete-subject-btn" title="Удалить предмет"
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
{% endif %}
</div>
</div>
{# ── Описание ── #}
<div class="t-subj-section subject-description" data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
<p class="description-text t-subj-desc {% if not (cfg and cfg.description) %}text-muted t-desc-italic{% endif %}">
{{ cfg.description if cfg and cfg.description else 'Нет описания' }}
</p>
{% if can_edit %}
<button type="button" class="t-btn t-btn-ghost t-btn-xs edit-ctrl edit-desc-btn">Изменить описание</button>
<div class="edit-desc-form t-subject-desc-form">
<textarea class="t-input t-textarea desc-input" rows="2"
aria-label="Описание предмета">{{ cfg.description if cfg and cfg.description else '' }}</textarea>
<div class="t-subj-desc-actions">
<button type="button" class="t-btn t-btn-primary t-btn-sm save-desc-btn">Сохранить</button>
<button type="button" class="t-btn t-btn-outline t-btn-sm cancel-desc-btn">Отмена</button>
</div>
</div>
{% endif %}
</div>
{# ── Мета: тип сдачи + ссылка СДО ── #}
<div class="t-subj-section t-subj-meta-section" data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
<div class="t-subj-meta-row">
{# Тип сдачи #}
<div class="t-subj-meta-item">
<span class="t-subj-meta-label">Тип сдачи</span>
{% if can_edit %}
<select class="t-select t-select-sm completion-type-select"
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
aria-label="Тип сдачи">
<option value="">— не указан —</option>
{% for ct in completion_types %}
<option value="{{ ct }}" {{ 'selected' if cfg and cfg.completion_type == ct else '' }}>{{ ct }}</option>
{% endfor %}
</select>
{% else %}
<span class="t-subj-meta-value">{{ (cfg.completion_type if cfg and cfg.completion_type else '—') }}</span>
{% endif %}
</div>
{# Ссылка СДО #}
<div class="t-subj-meta-item t-subj-meta-sdo">
<span class="t-subj-meta-label">СДО</span>
{% if can_edit %}
<div class="t-subj-sdo-edit">
<input type="url" class="t-input t-input-sm sdo-url-input"
value="{{ cfg.sdo_url if cfg and cfg.sdo_url else '' }}"
placeholder="https://..." aria-label="Ссылка на СДО">
<button type="button" class="t-btn t-btn-primary t-btn-sm save-sdo-btn" title="Сохранить ссылку">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
</button>
{% if cfg and cfg.sdo_url %}
<a href="{{ cfg.sdo_url }}" target="_blank" rel="noopener noreferrer"
class="t-btn t-btn-outline t-btn-sm" title="Открыть СДО">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
{% endif %}
</div>
{% else %}
{% if cfg and cfg.sdo_url %}
<a href="{{ cfg.sdo_url }}" target="_blank" rel="noopener noreferrer" class="t-subj-sdo-link">
<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Открыть
</a>
{% else %}
<span class="t-subj-meta-value">—</span>
{% endif %}
{% endif %}
</div>
</div>
</div>
{# ── Расписание: по типам и периодам ── #}
{% for type_name, type_data in item.get('types', {}).items() %}
{% set color = type_data.get('color', 'gray') if type_data is mapping and 'color' in type_data else 'gray' %}
{% set date_ranges = type_data.get('dates', type_data) if type_data is mapping and 'dates' in type_data else type_data %}
{% for dr, slots in date_ranges.items() %}
<div class="t-subj-section t-subj-slots-section"
data-subject="{{ subj }}"
data-type="{{ type_name }}"
data-period="{{ dr }}"
data-schedule="{{ schedule.id }}">
{# Заголовок секции: тип + период #}
<div class="t-subj-slots-head">
<span class="t-type t-type-{{ color }}">{{ type_name }}</span>
<span class="t-subj-period-display fs-small mono">{{ dr }}</span>
{% if can_edit %}
<button type="button" class="t-btn-icon edit-ctrl edit-period-btn" title="Изменить период">
<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</button>
<div class="t-subj-period-form hidden">
<div class="t-input-group">
<input type="text" class="t-input t-input-sm period-input" value="{{ dr }}"
placeholder="01.09-31.01" aria-label="Период">
<button type="button" class="t-btn t-btn-primary t-btn-group-end save-period-btn">✓</button>
</div>
</div>
{% endif %}
{% if can_edit %}
<button type="button" class="t-btn t-btn-outline t-btn-xs edit-ctrl add-slot-btn ml-auto"
title="Добавить строку"
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
data-type="{{ type_name }}" data-period="{{ dr }}">
+ Строка
</button>
{% endif %}
</div>
{# Таблица слотов #}
<table class="t-table t-subj-slot-table">
<thead>
<tr>
<th scope="col">День</th>
<th scope="col">Время</th>
<th scope="col">Кабинет</th>
<th scope="col">Преподаватель</th>
{% if can_edit %}<th scope="col" class="t-table-actions"><span class="hidden">Действия</span></th>{% endif %}
</tr>
</thead>
<tbody class="slots-tbody">
{% for slot in slots %}
{% set slot_day = slot[0] if slot|length > 0 else '' %}
{% set slot_time = slot[1] if slot|length > 1 else '' %}
{% set slot_room = slot[2] if slot|length > 2 and slot[2] else '' %}
{% set slot_teach = slot[3] if slot|length > 3 and slot[3] else '' %}
<tr class="t-slot-row"
data-day="{{ slot_day }}"
data-time="{{ slot_time }}"
data-type="{{ type_name }}"
data-period="{{ dr }}"
data-subject="{{ subj }}"
data-schedule="{{ schedule.id }}">
{# Режим просмотра #}
<td class="slot-view-day">{{ slot_day | capitalize }}</td>
<td class="slot-view-time mono fs-small">{{ slot_time or '—' }}</td>
<td class="slot-view-room">{{ slot_room or '—' }}</td>
<td class="slot-view-teach">{{ slot_teach or '—' }}</td>
{% if can_edit %}
<td>
<div class="t-table-actions-cell edit-ctrl">
<button type="button" class="t-btn-icon slot-inline-edit-btn" title="Редактировать строку">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button type="button" class="t-btn-icon danger slot-delete-btn" title="Удалить строку"
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
data-day="{{ slot_day }}" data-time="{{ slot_time }}"
data-type="{{ type_name }}" data-daterange="{{ dr }}">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div>
</td>
{% endif %}
</tr>
{# Строка редактирования — скрыта по умолчанию #}
{% if can_edit %}
<tr class="t-slot-edit-row hidden"
data-day="{{ slot_day }}"
data-time="{{ slot_time }}"
data-type="{{ type_name }}"
data-period="{{ dr }}"
data-subject="{{ subj }}"
data-schedule="{{ schedule.id }}">
<td><input type="text" class="t-input t-input-sm edit-day" value="{{ slot_day }}" placeholder="пн" aria-label="День"></td>
<td><input type="text" class="t-input t-input-sm edit-time" value="{{ slot_time }}" placeholder="09:00-10:30" aria-label="Время"></td>
<td><input type="text" class="t-input t-input-sm edit-room" value="{{ slot_room }}" placeholder="Кабинет" aria-label="Кабинет"></td>
<td><input type="text" class="t-input t-input-sm edit-teach" value="{{ slot_teach }}" placeholder="Преподаватель" aria-label="Преподаватель"></td>
<td>
<div class="t-slot-edit-save-row">
<button type="button" class="t-btn-icon slot-save-btn" title="Сохранить">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button type="button" class="t-btn-icon slot-cancel-btn" title="Отмена">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
{% endfor %}
{# ── Кнопка добавить новый тип/период (для можно расширить позже) ── #}
{# ── Трекеры ── #}
<div class="t-subj-section">
<div class="t-subj-section-head">
<div class="t-subj-section-title">Трекеры</div>
{% if can_edit %}
<button type="button" class="t-grid-edit-btn subj-grid-edit-btn"
data-grid="metrics-subj-{{ pidx }}" title="Редактировать расположение">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Расположение
</button>
{% endif %}
</div>
<div class="t-metrics-grid" id="metrics-subj-{{ pidx }}"
data-context="subject" data-schedule="{{ schedule.id }}">
{% if cfg %}
{% for metric in cfg.metrics %}
{{ render_metric(metric, can_edit, show_rename=True) }}
{% endfor %}
{% endif %}
</div>
{% if can_edit %}
<div class="add-metric-section t-subj-add-metric"
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
data-slot="subj-{{ pidx }}">
<div class="t-add-metric-btns">
<button type="button" class="t-btn t-btn-outline t-btn-sm ai-metric-btn">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
AI
</button>
<button type="button" class="t-btn t-btn-outline t-btn-sm manual-metric-btn">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Добавить трекер
</button>
</div>
<div class="ai-metric-form t-metric-form">
<div class="t-input-group">
<input type="text" class="t-input t-input-sm ai-prompt-input"
placeholder="Напр: 12 лабораторных работ"
aria-label="Описание для AI-генерации трекера">
<button type="button" class="t-btn t-btn-primary t-ai-submit ai-prompt-submit" title="Сгенерировать трекер">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</button>
</div>
</div>
<div class="manual-metric-form t-metric-form">
<div class="t-two-col">
<select class="t-select t-select-sm metric-type-select" aria-label="Тип трекера">
<option value="">Тип трекера</option>
<option value="checkpoints">Контрольные точки</option>
<option value="progress_bar">Накопление баллов</option>
<option value="counter">Счётчик</option>
<option value="stages">Этапы</option>
<option value="checklist">Чеклист</option>
<option value="attendance">Посещаемость</option>
<option value="grades">Оценки</option>
<option value="deadlines">Дедлайны</option>
<option value="streak">Серия</option>
<option value="effort_hours">Трудозатраты</option>
<option value="rating_history">История оценок</option>
</select>
<input type="text" class="t-input t-input-sm metric-label-input"
placeholder="Название трекера"
aria-label="Название трекера">
</div>
<div class="metric-config-area"></div>
<button type="button" class="t-btn t-btn-primary t-btn-sm manual-metric-submit">Добавить</button>
</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# ══ Модальное окно промта AI-трекера ══ #}
<div class="t-modal-backdrop" id="aiPromptModalBackdrop" role="dialog" aria-modal="true" aria-labelledby="ai-prompt-modal-title">
<div class="t-modal t-modal-sm">
<div class="t-modal-header">
<span class="t-modal-title" id="ai-prompt-modal-title">Промт трекера</span>
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('aiPromptModalBackdrop')" title="Закрыть">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="t-modal-body">
<p class="t-label">Запрос, который использовался при создании трекера через AI:</p>
<div class="t-ai-prompt-display" id="ai-prompt-text"></div>
</div>
<div class="t-modal-footer">
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('aiPromptModalBackdrop')">Закрыть</button>
</div>
</div>
</div>
{# ══ Модальное окно переименования трекера ══ #}
<div class="t-modal-backdrop" id="renameMetricModalBackdrop" role="dialog" aria-modal="true" aria-labelledby="rename-metric-modal-title">
<div class="t-modal t-modal-sm">
<div class="t-modal-header">
<span class="t-modal-title" id="rename-metric-modal-title">Переименовать трекер</span>
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('renameMetricModalBackdrop')" title="Закрыть">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="t-modal-body">
<label class="t-label" for="rename-metric-input">Новое название</label>
<input type="text" class="t-input" id="rename-metric-input" aria-label="Новое название трекера">
</div>
<div class="t-modal-footer">
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('renameMetricModalBackdrop')">Отмена</button>
<button type="button" class="t-btn t-btn-primary" id="rename-metric-confirm-btn">Сохранить</button>
</div>
</div>
</div>
{# ══ Модальное окно переименования ══ #}
<div class="t-modal-backdrop" id="renameModalBackdrop" role="dialog" aria-modal="true" aria-labelledby="rename-modal-title">
<div class="t-modal t-modal-sm">
<div class="t-modal-header">
<span class="t-modal-title" id="rename-modal-title">Переименовать</span>
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('renameModalBackdrop')" title="Закрыть">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="t-modal-body">
<label class="t-label" for="rename-input">Новое название</label>
<input type="text" class="t-input" id="rename-input" aria-label="Новое название">
</div>
<div class="t-modal-footer">
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('renameModalBackdrop')">Отмена</button>
<button type="button" class="t-btn t-btn-primary" id="rename-confirm-btn">Сохранить</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/subjects.js') }}"></script>
<script src="{{ url_for('static', filename='js/metrics.js') }}"></script>
<script>
const SCHEDULE_ID = {{ schedule.id }};
const CAN_EDIT = {{ 'true' if can_edit else 'false' }};
document.querySelectorAll('.t-modal-backdrop').forEach(bd => {
bd.addEventListener('click', e => { if (e.target === bd) bd.classList.remove('open'); });
});
</script>
{% endblock %}

View file

@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Новое расписание{% endblock %}
{% block content %}
<div class="t-ph">
<h1 class="t-ph-title">Добавить расписание</h1>
</div>
<div class="t-upload-card">
<form method="POST" enctype="multipart/form-data" id="upload-form" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="t-field">
<label class="t-label" for="sched-name">Название расписания</label>
<input type="text" name="name" id="sched-name" class="t-input"
placeholder="Например: 2 курс, осенний семестр" required
autocomplete="off">
</div>
<div class="t-field">
<label class="t-label" for="image-input">Изображение расписания</label>
<label class="t-file-drop" for="image-input" id="file-drop-zone" aria-label="Загрузить изображение">
<input type="file" name="image" id="image-input"
accept=".jpg,.jpeg,.png,.webp,.bmp" required>
<svg width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div class="t-upload-drop-text">Выберите или перетащите файл</div>
<div class="t-hint">JPG, PNG, WebP, BMP</div>
</label>
<div id="image-preview" class="t-image-preview">
<img id="preview-img" class="t-preview-img" alt="Предпросмотр расписания">
<button type="button" class="t-btn t-btn-ghost t-btn-sm t-change-file-btn" id="change-file-btn">
Выбрать другой файл
</button>
<input type="file" name="image_replace" id="image-input-replace"
accept=".jpg,.jpeg,.png,.webp,.bmp" hidden>
</div>
</div>
<div class="t-field">
<label class="t-label" for="extra-prompt">
Дополнительные указания для нейросети
<span class="text-muted fs-small">(необязательно)</span>
</label>
<textarea name="extra_prompt" id="extra-prompt" class="t-input t-textarea" rows="3"
placeholder="Например: таблица читается слева направо, первый столбец — день недели"></textarea>
</div>
<button type="submit" class="t-btn t-btn-primary t-btn-full" id="scan-btn">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Сканировать расписание
</button>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
function showPreview(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
document.getElementById('preview-img').src = ev.target.result;
document.getElementById('image-preview').classList.add('visible');
document.getElementById('file-drop-zone').classList.add('hidden');
};
reader.readAsDataURL(file);
}
document.getElementById('image-input').addEventListener('change', e => {
showPreview(e.target.files[0]);
});
document.getElementById('change-file-btn').addEventListener('click', () => {
document.getElementById('file-drop-zone').classList.remove('hidden');
document.getElementById('image-preview').classList.remove('visible');
document.getElementById('image-input').value = '';
document.getElementById('preview-img').src = '';
});
document.getElementById('upload-form').addEventListener('submit', () => {
const btn = document.getElementById('scan-btn');
btn.disabled = true;
btn.innerHTML = '<span class="t-spinner"></span> Сканирую...';
});
</script>
{% endblock %}

View file

@ -0,0 +1,453 @@
{% extends "base.html" %}
{% block title %}{{ schedule.name }}{% endblock %}
{% block nav_extra %}{% endblock %}
{% block content %}
{% from "macros/metrics.html" import render_metric %}
{% set TYPE_SHORT = {
'Лекция': 'Лек',
'Практика': 'Пр',
'Лаб. работа': 'Лаб',
'Лабораторная': 'Лаб',
'Семинар': 'Сем',
'Не указано': ''
} %}
{# ── Шапка ── #}
<div class="t-ph">
<div class="t-ph-name-row">
<h1 class="t-ph-title schedule-name-display">{{ schedule.name }}</h1>
{% if can_edit and not is_shared_view %}
<button type="button" class="t-btn-icon edit-ctrl rename-schedule-btn" title="Переименовать расписание">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
{% endif %}
</div>
<div class="t-ph-right">
{% if can_edit and not is_shared_view %}
<a class="t-btn t-btn-outline" href="{{ url_for('subjects.subjects_page', schedule_id=schedule.id) }}">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
Предметы
</a>
{% endif %}
{% if can_edit and not is_shared_view %}
<button type="button" class="t-btn t-btn-outline" id="share-schedule-btn" data-schedule-id="{{ schedule.id }}">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
Поделиться
</button>
{% endif %}
</div>
</div>
{# ══════════════════════════════ РАСПИСАНИЕ ══════════════════════════════ #}
<div class="t-grid" id="schedule-grid">
{% for i in range(7) %}
{% set day_slots = week_view[i] %}
{% if day_slots %}
<div class="t-day-card {{ 'today' if i == today_idx else '' }}">
<div class="t-day-card-head {{ 'today' if i == today_idx else '' }}">
<span class="t-day-name">{{ days_display[i] }}</span>
{% if i == today_idx %}<span class="t-badge-today">сегодня</span>{% endif %}
<span class="t-day-date">{{ week_dates[i].strftime('%d.%m') }}</span>
</div>
{% for slot in day_slots %}
{% set sid = 'se-' ~ i ~ '-' ~ loop.index0 %}
{% if slot.get('_group') %}
{# Несколько предметов в одно время — показываем все сразу (фильтруем скрытые) #}
{% set visible_slots = [] %}
{% for s in slot.slots %}
{% set _cfg = configs.get(s.subject) %}
{% if not (_cfg and _cfg.hidden) %}{% set _ = visible_slots.append(s) %}{% endif %}
{% endfor %}
{% if visible_slots %}
<div class="t-slot-group">
<div class="t-slot-group-time">
<span class="t-slot-time">{{ slot.time or '—' }}</span>
{% if visible_slots | length > 1 %}<span class="t-slot-group-badge">{{ visible_slots | length }} предмета</span>{% endif %}
</div>
<div class="t-slot-group-items">
{% for s in visible_slots %}
<div class="t-slot-group-item">
<div class="min-w-0 flex-1">
<span class="t-slot-name">{{ s.subject }}</span>
{% if s.classroom %}
<div class="t-slot-room">
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ s.classroom }}
</div>
{% endif %}
</div>
{% set t_short = TYPE_SHORT.get(s.type, s.type[:3] if s.type and s.type|length > 4 else s.type) %}
{% if t_short %}
<span class="t-type t-type-{{ s.color or 'gray' }}">{{ t_short }}</span>
{% endif %}
<div class="edit-ctrl t-slot-edit-controls">
<button type="button" class="t-btn-icon danger slot-delete-btn" title="Удалить"
data-schedule="{{ schedule.id }}" data-subject="{{ s.subject }}"
data-day="{{ s.day }}" data-time="{{ s.time }}"
data-type="{{ s.type_name }}" data-daterange="{{ s.date_range }}">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}{# visible_slots #}
{% else %}
{# Обычный слот — пропустить если предмет скрыт #}
{% set _slot_cfg = configs.get(slot.subject) %}
{% if not (_slot_cfg and _slot_cfg.hidden) %}
{% set merged_type = '/' in (slot.type or '') %}
<div class="t-slot" id="slot-{{ sid }}" data-slot-id="{{ sid }}">
<div class="t-slot-time">{{ slot.time or '—' }}</div>
<div class="t-slot-body">
<div class="t-slot-name">{{ slot.subject }}</div>
{% if slot.classroom %}
<div class="t-slot-room">
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ slot.classroom }}
</div>
{% endif %}
</div>
<div class="t-slot-right">
{% set t_short = TYPE_SHORT.get(slot.type, slot.type[:3] if slot.type and slot.type|length > 4 else slot.type) %}
{% if t_short and slot.type != 'Не указано' %}
<span class="t-type t-type-{{ slot.color or 'gray' }}">{{ t_short }}</span>
{% endif %}
{% if not merged_type %}
<div class="edit-ctrl t-slot-edit-controls">
<button type="button" class="t-btn-icon slot-edit-time-btn" title="Изменить время"
data-schedule="{{ schedule.id }}" data-subject="{{ slot.subject }}"
data-day="{{ slot.day }}" data-time="{{ slot.time }}"
data-type="{{ slot.type_name }}" data-daterange="{{ slot.date_range }}">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</button>
<button type="button" class="t-btn-icon danger slot-delete-btn" title="Удалить слот"
data-schedule="{{ schedule.id }}" data-subject="{{ slot.subject }}"
data-day="{{ slot.day }}" data-time="{{ slot.time }}"
data-type="{{ slot.type_name }}" data-daterange="{{ slot.date_range }}">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div>
{% endif %}
<span class="t-chevron">▾</span>
</div>
</div>
<div class="t-slot-detail" id="detail-{{ sid }}">
{% if slot.teacher %}
<div class="t-slot-teacher">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{{ slot.teacher }}
</div>
{% endif %}
{% if slot.date_range and slot.date_range != 'Весь период' %}
<div class="t-slot-teacher">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{{ slot.date_range }}
</div>
{% endif %}
{% set cfg = configs.get(slot.subject) %}
{% if cfg and cfg.completion_type %}
<div class="t-slot-teacher">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>
{{ cfg.completion_type }}
{% if cfg.completed %}<span class="t-slot-completed-badge">Завершён</span>{% endif %}
</div>
{% elif cfg and cfg.completed %}
<div class="t-slot-teacher">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
<span class="t-slot-completed-badge">Завершён</span>
</div>
{% endif %}
{% if cfg and cfg.sdo_url %}
<div class="t-slot-teacher">
<a href="{{ cfg.sdo_url }}" target="_blank" rel="noopener noreferrer" class="t-slot-sdo-btn">
<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Открыть в СДО
</a>
</div>
{% endif %}
{% if cfg and cfg.description %}
<div class="t-slot-desc">{{ cfg.description }}</div>
{% endif %}
{% set metrics = cfg.metrics | list if cfg else [] %}
{% if metrics %}
<div class="t-slot-metrics">
{% for m in metrics %}
<a href="#metric-{{ m.id }}" class="t-slot-metric-link" data-metric-id="{{ m.id }}">{{ m.label }}</a>
{% endfor %}
</div>
{% else %}
<div class="text-muted fs-small t-desc-italic">Трекеры не добавлены</div>
{% endif %}
</div>
{% endif %}{# not hidden #}
{% endif %}{# _group / regular slot #}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{# ══════════════════════════════ ДЭШБОРД ══════════════════════════════ #}
{% if all_metrics %}
{# Вычисляем сколько трекеров заполнены #}
{% set completed_count = namespace(n=0) %}
{% set filled_ids = [] %}
{% set total_count = all_metrics | length %}
{% for entry in all_metrics %}
{% set m = entry.metric %}
{% set prog = m.progress or {} %}
{% set cfg_m = m.config or {} %}
{% set is_full = false %}
{% if m.type == 'checkpoints' %}
{% set is_full = prog.get('checked', []) | length >= cfg_m.get('total', 1) | int %}
{% elif m.type == 'progress_bar' %}
{% set is_full = prog.get('current', 0) | float >= cfg_m.get('max', 1) | float %}
{% elif m.type == 'counter' %}
{% set is_full = cfg_m.get('max') and prog.get('current', 0) | int >= cfg_m.get('max') | int %}
{% elif m.type == 'checklist' %}
{% set is_full = cfg_m.get('items', []) | length > 0 and prog.get('checked', []) | length >= cfg_m.get('items', []) | length %}
{% elif m.type == 'deadlines' %}
{% set is_full = cfg_m.get('items', []) | length > 0 and prog.get('completed', []) | length >= cfg_m.get('items', []) | length %}
{% elif m.type == 'stages' %}
{% set is_full = cfg_m.get('stages', []) | length > 0 and prog.get('current_stage', 0) | int >= cfg_m.get('stages', []) | length %}
{% elif m.type == 'effort_hours' %}
{% set is_full = cfg_m.get('goal_hours') and prog.get('hours', 0) | float >= cfg_m.get('goal_hours') | float %}
{% endif %}
{% if is_full %}{% set completed_count.n = completed_count.n + 1 %}{% endif %}
{% endfor %}
<div class="t-dash-title-row" id="dashboard-section">
<span class="t-section-title t-section-title-inline">Трекеры</span>
<div class="t-dash-title-actions">
{% if completed_count.n > 0 %}
<button type="button" class="t-btn t-btn-ghost t-btn-xs" id="toggle-filled-btn"
data-showing="false" title="Показать/скрыть заполненные трекеры">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
Показать заполненные ({{ completed_count.n }})
</button>
{% endif %}
{% if can_edit and not is_shared_view %}
<button type="button" class="t-grid-edit-btn" id="dash-edit-btn"
data-grid="dashboard-grid" title="Редактировать расположение">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Расположение
</button>
{% endif %}
</div>
</div>
<div class="t-metrics-grid" id="dashboard-grid"
data-context="dashboard" data-schedule="{{ schedule.id }}">
{% for entry in all_metrics %}
{% set m = entry.metric %}
{% set prog = m.progress or {} %}
{% set cfg_m = m.config or {} %}
{% set is_full = false %}
{% if m.type == 'checkpoints' %}
{% set is_full = prog.get('checked', []) | length >= cfg_m.get('total', 1) | int and cfg_m.get('total', 0) | int > 0 %}
{% elif m.type == 'progress_bar' %}
{% set is_full = prog.get('current', 0) | float >= cfg_m.get('max', 1) | float and cfg_m.get('max', 0) | float > 0 %}
{% elif m.type == 'counter' %}
{% set is_full = cfg_m.get('max') and prog.get('current', 0) | int >= cfg_m.get('max') | int %}
{% elif m.type == 'checklist' %}
{% set is_full = cfg_m.get('items', []) | length > 0 and prog.get('checked', []) | length >= cfg_m.get('items', []) | length %}
{% elif m.type == 'deadlines' %}
{% set is_full = cfg_m.get('items', []) | length > 0 and prog.get('completed', []) | length >= cfg_m.get('items', []) | length %}
{% elif m.type == 'stages' %}
{% set is_full = cfg_m.get('stages', []) | length > 0 and prog.get('current_stage', 0) | int >= cfg_m.get('stages', []) | length %}
{% elif m.type == 'effort_hours' %}
{% set is_full = cfg_m.get('goal_hours') and prog.get('hours', 0) | float >= cfg_m.get('goal_hours') | float %}
{% endif %}
{% if is_full %}{% set _ = filled_ids.append(m.id) %}{% endif %}
{{ render_metric(m, can_edit) }}
{% endfor %}
</div>
{% if filled_ids %}
<script>
[{{ filled_ids | join(', ') }}].forEach(id => {
const el = document.getElementById('metric-' + id);
if (el) el.classList.add('metric-filled-hidden');
});
</script>
{% endif %}
{% endif %}
{# ══ Модальное окно шаринга ══ #}
{% if can_edit and not is_shared_view %}
<div class="t-modal-backdrop" id="shareModalBackdrop" role="dialog" aria-modal="true" aria-label="Поделиться расписанием">
<div class="t-modal">
<div class="t-modal-header">
<span class="t-modal-title">Поделиться расписанием</span>
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('shareModalBackdrop')" title="Закрыть">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="t-modal-body">
<div class="t-field">
<label class="t-label" for="link-view">Ссылка для просмотра</label>
<div class="t-input-group">
<input type="text" class="t-input" id="link-view" readonly aria-label="Ссылка для просмотра">
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-view" title="Скопировать ссылку">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
<div class="t-field">
<label class="t-label" for="link-template">Ссылка-шаблон</label>
<div class="t-input-group">
<input type="text" class="t-input" id="link-template" readonly aria-label="Ссылка-шаблон">
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-template" title="Скопировать ссылку">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
<hr class="t-divider">
<div class="t-field">
<label class="t-label" for="editor-login">Добавить редактора</label>
<div class="t-input-group">
<input type="text" class="t-input" id="editor-login" placeholder="Логин пользователя" aria-label="Логин редактора">
<button type="button" class="t-btn t-btn-primary" id="add-editor-btn" title="Добавить редактора" class="t-btn-group-end">Добавить</button>
</div>
</div>
<div id="editor-link-wrap" hidden class="t-field">
<label class="t-label" for="link-edit">Ссылка для редактора</label>
<div class="t-input-group">
<input type="text" class="t-input" id="link-edit" readonly aria-label="Ссылка для редактора">
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-edit" title="Скопировать ссылку">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{# ══ Модальное окно переименования ══ #}
<div class="t-modal-backdrop" id="renameModalBackdrop" role="dialog" aria-modal="true" aria-labelledby="rename-modal-title">
<div class="t-modal t-modal-sm">
<div class="t-modal-header">
<span class="t-modal-title" id="rename-modal-title">Переименовать</span>
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('renameModalBackdrop')" title="Закрыть">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="t-modal-body">
<label class="t-label" for="rename-input">Новое название</label>
<input type="text" class="t-input" id="rename-input" aria-label="Новое название">
</div>
<div class="t-modal-footer">
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('renameModalBackdrop')">Отмена</button>
<button type="button" class="t-btn t-btn-primary" id="rename-confirm-btn">Сохранить</button>
</div>
</div>
</div>
{# ══ Модальное окно изменения времени ══ #}
<div class="t-modal-backdrop" id="slotTimeModalBackdrop" role="dialog" aria-modal="true" aria-label="Изменить время слота">
<div class="t-modal t-modal-sm">
<div class="t-modal-header">
<span class="t-modal-title">Изменить время слота</span>
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('slotTimeModalBackdrop')" title="Закрыть">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="t-modal-body">
<label class="t-label" for="slot-time-input">Время (формат 09:00-10:30)</label>
<input type="text" class="t-input" id="slot-time-input" placeholder="09:00-10:30" aria-label="Время слота">
</div>
<div class="t-modal-footer">
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('slotTimeModalBackdrop')">Отмена</button>
<button type="button" class="t-btn t-btn-primary" id="slot-time-save-btn">Сохранить</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/schedule.js') }}"></script>
<script src="{{ url_for('static', filename='js/subjects.js') }}"></script>
<script src="{{ url_for('static', filename='js/metrics.js') }}"></script>
<script>
const SCHEDULE_ID = {{ schedule.id }};
const CAN_EDIT = {{ 'true' if can_edit else 'false' }};
/* ── Заполненные трекеры: показать/скрыть ── */
(function() {
const btn = document.getElementById('toggle-filled-btn');
if (!btn) return;
const filled = document.querySelectorAll('.t-metric.metric-filled-hidden');
const count = filled.length;
let showing = false;
btn.addEventListener('click', () => {
showing = !showing;
filled.forEach(el => el.classList.toggle('metric-filled-hidden', !showing));
btn.innerHTML = showing
? `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Скрыть заполненные (${count})`
: `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg> Показать заполненные (${count})`;
});
})();
/* ── Слоты: разворот ── */
document.querySelectorAll('.t-slot').forEach(slot => {
slot.addEventListener('click', e => {
if (e.target.closest('button')) return;
const sid = slot.dataset.slotId;
const detail = document.getElementById('detail-' + sid);
if (!detail) return;
const isOpen = slot.classList.contains('open');
slot.classList.toggle('open', !isOpen);
detail.style.display = isOpen ? 'none' : 'block';
});
});
/* ── Модальные окна: закрытие по клику на backdrop ── */
document.querySelectorAll('.t-modal-backdrop').forEach(bd => {
bd.addEventListener('click', e => { if (e.target === bd) bd.classList.remove('open'); });
});
/* ── Кнопки копирования ── */
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
navigator.clipboard.writeText(document.getElementById(btn.dataset.target).value);
const orig = btn.innerHTML;
btn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
setTimeout(() => btn.innerHTML = orig, 1500);
});
});
</script>
{% if can_edit and not is_shared_view %}
<script>
document.getElementById('share-schedule-btn')?.addEventListener('click', async () => {
for (const type of ['view', 'template']) {
const res = await apiFetch(`/schedules/${SCHEDULE_ID}/share`, 'POST', { type });
if (res.ok)
document.getElementById(`link-${type}`).value = `${location.origin}/shared/${res.token}`;
}
openModal('shareModalBackdrop');
});
document.getElementById('add-editor-btn')?.addEventListener('click', async () => {
const login = document.getElementById('editor-login').value.trim();
if (!login) return;
const res = await apiFetch(`/schedules/${SCHEDULE_ID}/share/editors`, 'POST', { login });
if (res.ok) {
document.getElementById('link-edit').value = `${location.origin}/shared/${res.token}`;
document.getElementById('editor-link-wrap').hidden = false;
} else alert(res.error || 'Ошибка');
});
</script>
{% endif %}
{% endblock %}

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View file

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View file

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,102 @@
"""init
Revision ID: a9875eef468a
Revises:
Create Date: 2026-04-01 09:39:29.714693
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a9875eef468a'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('login', sa.String(length=64), nullable=False),
sa.Column('password_hash', sa.String(length=256), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_login'), ['login'], unique=True)
op.create_table('schedules',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('shares',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('schedule_id', sa.Integer(), nullable=False),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('share_type', sa.String(length=16), nullable=False),
sa.Column('token', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['schedule_id'], ['schedules.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token')
)
op.create_table('subject_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('schedule_id', sa.Integer(), nullable=False),
sa.Column('subject_name', sa.String(length=256), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['schedule_id'], ['schedules.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('schedule_id', 'subject_name', name='uq_schedule_subject')
)
op.create_table('metrics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('subject_config_id', sa.Integer(), nullable=False),
sa.Column('type', sa.String(length=32), nullable=False),
sa.Column('label', sa.String(length=256), nullable=False),
sa.Column('config', sa.JSON(), nullable=False),
sa.ForeignKeyConstraint(['subject_config_id'], ['subject_configs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('share_editors',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('share_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['share_id'], ['shares.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('share_id', 'user_id', name='uq_share_editor')
)
op.create_table('metric_progress',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('metric_id', sa.Integer(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['metric_id'], ['metrics.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('metric_progress')
op.drop_table('share_editors')
op.drop_table('metrics')
op.drop_table('subject_configs')
op.drop_table('shares')
op.drop_table('schedules')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_login'))
op.drop_table('users')
# ### end Alembic commands ###

View file

@ -0,0 +1,40 @@
"""merge progress into metric
Revision ID: aa6f28d23573
Revises: a9875eef468a
Create Date: 2026-05-03 11:13:14.874531
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'aa6f28d23573'
down_revision = 'a9875eef468a'
branch_labels = None
depends_on = None
def upgrade():
op.drop_table('metric_progress')
with op.batch_alter_table('metrics', schema=None) as batch_op:
batch_op.add_column(sa.Column('progress', sa.JSON(), nullable=False, server_default='{}'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('metrics', schema=None) as batch_op:
batch_op.drop_column('progress')
op.create_table('metric_progress',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('metric_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('data', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['metric_id'], ['metrics.id'], name='metric_progress_metric_id_fkey'),
sa.PrimaryKeyConstraint('id', name='metric_progress_pkey')
)
# ### end Alembic commands ###

View file

@ -0,0 +1,30 @@
"""add grid layout to metrics
Revision ID: b3c9d1e2f4a5
Revises: aa6f28d23573
Create Date: 2026-05-03
"""
from alembic import op
import sqlalchemy as sa
revision = 'b3c9d1e2f4a5'
down_revision = 'aa6f28d23573'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('metrics') as batch_op:
batch_op.add_column(sa.Column('grid_col', sa.Integer(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('grid_row', sa.Integer(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('grid_w', sa.Integer(), nullable=False, server_default='1'))
batch_op.add_column(sa.Column('grid_h', sa.Integer(), nullable=False, server_default='1'))
def downgrade():
with op.batch_alter_table('metrics') as batch_op:
batch_op.drop_column('grid_h')
batch_op.drop_column('grid_w')
batch_op.drop_column('grid_row')
batch_op.drop_column('grid_col')

View file

@ -0,0 +1,22 @@
"""grid_h in half-row units (1=0.5row, 2=1row, 4=2rows)
Revision ID: c4d2e3f5a6b7
Revises: b3c9d1e2f4a5
Create Date: 2026-05-03
"""
from alembic import op
revision = 'c4d2e3f5a6b7'
down_revision = 'b3c9d1e2f4a5'
branch_labels = None
depends_on = None
def upgrade():
# Старые записи: grid_h=1 означало «1 строка» → теперь «2 полустроки»
op.execute("UPDATE metrics SET grid_h = grid_h * 2")
def downgrade():
op.execute("UPDATE metrics SET grid_h = GREATEST(grid_h / 2, 1)")

View file

@ -0,0 +1,26 @@
"""Add hidden flag to subject_configs
Revision ID: d5e3f4a6b7c8
Revises: c4d2e3f5a6b7
Create Date: 2026-05-04
"""
import sqlalchemy as sa
from alembic import op
revision = 'd5e3f4a6b7c8'
down_revision = 'c4d2e3f5a6b7'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('subject_configs') as batch_op:
batch_op.add_column(
sa.Column('hidden', sa.Boolean(), nullable=False, server_default='0')
)
def downgrade():
with op.batch_alter_table('subject_configs') as batch_op:
batch_op.drop_column('hidden')

View file

@ -0,0 +1,40 @@
"""Add completion_type, completed, sdo_url to subject_configs; ai_prompt to metrics
Revision ID: e6f4a5b8c9d0
Revises: d5e3f4a6b7c8
Create Date: 2026-05-11
"""
import sqlalchemy as sa
from alembic import op
revision = 'e6f4a5b8c9d0'
down_revision = 'd5e3f4a6b7c8'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('subject_configs') as batch_op:
batch_op.add_column(
sa.Column('completion_type', sa.String(32), nullable=True)
)
batch_op.add_column(
sa.Column('completed', sa.Boolean(), nullable=False, server_default='0')
)
batch_op.add_column(
sa.Column('sdo_url', sa.Text(), nullable=True)
)
with op.batch_alter_table('metrics') as batch_op:
batch_op.add_column(
sa.Column('ai_prompt', sa.Text(), nullable=True)
)
def downgrade():
with op.batch_alter_table('subject_configs') as batch_op:
batch_op.drop_column('sdo_url')
batch_op.drop_column('completed')
batch_op.drop_column('completion_type')
with op.batch_alter_table('metrics') as batch_op:
batch_op.drop_column('ai_prompt')

10
requirements.txt Normal file
View file

@ -0,0 +1,10 @@
Flask==3.1.0
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
Flask-Login==0.6.3
Flask-WTF==1.2.2
psycopg2-binary==2.9.9
openai==1.58.1
python-dotenv==1.0.1
Werkzeug==3.1.3
Pillow==11.0.0

9
run.py Normal file
View file

@ -0,0 +1,9 @@
import sys
sys.dont_write_bytecode = True
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True)