From 8542ce8e01e016a504e674bf3359711265ce5560 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sun, 22 Mar 2026 12:48:20 +0300 Subject: [PATCH] redisign and bug fix --- backend/api.py | 359 +++++----- backend/database.py | 53 +- backend/models.py | 7 +- backend/utils.py | 223 ++++-- frontend/admin/index.html | 63 +- frontend/admin/static/script.js | 1102 ++++++++++++++---------------- frontend/admin/static/style.css | 561 ++++++++++----- frontend/public/index.html | 41 +- frontend/public/static/script.js | 209 +++--- frontend/public/static/style.css | 173 +++-- 10 files changed, 1622 insertions(+), 1169 deletions(-) diff --git a/backend/api.py b/backend/api.py index 33b9d28..960d814 100644 --- a/backend/api.py +++ b/backend/api.py @@ -1,19 +1,13 @@ -from fastapi import APIRouter, Depends, HTTPException, Body -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_, or_ from datetime import datetime, timedelta -import pytz -from typing import List, Union -from backend.database import get_db, Task, Event, WeeklyTaskException -from backend.models import ( - TaskCreate, EventCreate, ScheduleResponse, ScheduleItem, - TaskResponse, EventResponse, UpdateRequest -) -from backend.utils import ( - get_week_range, check_event_overlap, get_weekday_from_date, - materialize_weekly_tasks, format_date_russian -) +import pytz +from fastapi import APIRouter, Depends, HTTPException, Body +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.database import get_db, Task, Event, WeeklyTaskException, EventException +from backend.models import TaskCreate, EventCreate, ScheduleResponse, ScheduleItem, UpdateRequest +from backend.utils import check_event_overlap, get_weekday_from_date, materialize_weekly_tasks, materialize_events router = APIRouter() @@ -26,16 +20,18 @@ async def get_schedule( to_date: str, db: AsyncSession = Depends(get_db) ): - """Получить расписание в диапазоне дат""" + """Получить расписание в диапазоне дат.""" try: from_dt = datetime.strptime(from_date, "%Y-%m-%d").date() to_dt = datetime.strptime(to_date, "%Y-%m-%d").date() except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") - + + if to_dt < from_dt: + raise HTTPException(status_code=400, detail="to_date must be greater than or equal to from_date") + items = [] - - # Получаем разовые tasks + result = await db.execute( select(Task).where( and_( @@ -51,34 +47,14 @@ async def get_schedule( kind="task", id=task.id, date=task.date, + source_date=task.date, title=task.title, repeat_weekly=False )) - - # Получаем events - result = await db.execute( - select(Event).where( - and_( - Event.date >= from_date, - Event.date <= to_date - ) - ) - ) - events = result.scalars().all() - for event in events: - items.append(ScheduleItem( - kind="event", - id=event.id, - date=event.date, - title=event.title, - start_time=event.start_time, - duration_min=event.duration_min - )) - - # Материализуем weekly tasks - weekly_tasks = await materialize_weekly_tasks(db, from_date, to_date) - items.extend(weekly_tasks) - + + items.extend(await materialize_weekly_tasks(db, from_date, to_date)) + items.extend(await materialize_events(db, from_date, to_date)) + return ScheduleResponse(items=items) @@ -88,74 +64,69 @@ async def create_item( data: dict = Body(...), db: AsyncSession = Depends(get_db) ): - """Создать task или event""" + """Создать task или event.""" if kind == "task": try: task = TaskCreate(**data) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Invalid task data: {e}") - - # Создаём основную task + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid task data: {exc}") + new_task = Task( date=task.date, title=task.title, repeat_weekly=task.repeat_weekly ) - if task.repeat_weekly: - weekday = get_weekday_from_date(task.date) - new_task.weekday = weekday - + new_task.weekday = get_weekday_from_date(task.date) + db.add(new_task) await db.commit() await db.refresh(new_task) - - # Копирование на другие дни недели + if task.copy_to_weekdays and not task.repeat_weekly: base_date = datetime.strptime(task.date, "%Y-%m-%d").date() base_weekday = base_date.weekday() for target_weekday in task.copy_to_weekdays: - # Вычисляем дату целевого дня недели в той же неделе - days_diff = (target_weekday - base_weekday) % 7 - if days_diff > 0: # только будущие дни - target_date = base_date + timedelta(days=days_diff) - copy_task = Task( - date=target_date.strftime("%Y-%m-%d"), - title=task.title, - repeat_weekly=False - ) - db.add(copy_task) + days_diff = target_weekday - base_weekday + if days_diff <= 0: + continue + target_date = base_date + timedelta(days=days_diff) + db.add(Task( + date=target_date.strftime("%Y-%m-%d"), + title=task.title, + repeat_weekly=False + )) await db.commit() - + return {"id": new_task.id, "kind": "task"} - - elif kind == "event": + + if kind == "event": try: event = EventCreate(**data) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Invalid event data: {e}") - - # Проверка пересечений - overlap = await check_event_overlap( - db, event.date, event.start_time, event.duration_min, None - ) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid event data: {exc}") + + overlap = await check_event_overlap(db, event.date, event.start_time, event.duration_min, None) if overlap: raise HTTPException(status_code=400, detail="Нельзя добавить: пересечение по времени") - + new_event = Event( date=event.date, start_time=event.start_time, duration_min=event.duration_min, - title=event.title + title=event.title, + repeat_weekly=event.repeat_weekly ) + if event.repeat_weekly: + new_event.weekday = get_weekday_from_date(event.date) + db.add(new_event) await db.commit() await db.refresh(new_event) - + return {"id": new_event.id, "kind": "event"} - - else: - raise HTTPException(status_code=400, detail="Invalid kind. Use 'task' or 'event'") + + raise HTTPException(status_code=400, detail="Invalid kind. Use 'task' or 'event'") @router.put("/events/{item_id}") @@ -164,130 +135,200 @@ async def update_item( update: UpdateRequest, db: AsyncSession = Depends(get_db) ): - """Обновить task или event""" - # Пробуем найти как task + """Обновить task или event.""" result = await db.execute(select(Task).where(Task.id == item_id)) task = result.scalar_one_or_none() - + if task: if task.repeat_weekly and update.scope == "one_date": - # Создаём исключение для конкретной даты - weekday = get_weekday_from_date(task.date) + occurrence_date = update.occurrence_date or task.date + result = await db.execute( + select(WeeklyTaskException).where( + and_( + WeeklyTaskException.task_id == task.id, + WeeklyTaskException.date == occurrence_date + ) + ) + ) + for existing_exception in result.scalars().all(): + await db.delete(existing_exception) exception = WeeklyTaskException( - weekday=weekday, - date=task.date, - action="replace" if update.title else "delete", - replacement_title=update.title + task_id=task.id, + weekday=get_weekday_from_date(occurrence_date), + date=occurrence_date, + action="replace", + replacement_title=update.title or task.title, + replacement_date=update.date or occurrence_date ) db.add(exception) await db.commit() return {"success": True, "action": "exception_created"} - + if update.title: - if task.repeat_weekly and update.scope == "series": - # Обновляем все weekly tasks с этим weekday - await db.execute( - Task.__table__.update() - .where(Task.weekday == task.weekday) - .values(title=update.title) - ) - else: - task.title = update.title - + task.title = update.title + if update.date: + task.date = update.date + if task.repeat_weekly: + task.weekday = get_weekday_from_date(update.date) + await db.commit() return {"success": True} - - # Пробуем найти как event + result = await db.execute(select(Event).where(Event.id == item_id)) event = result.scalar_one_or_none() - - if event: - new_date = update.date or event.date - new_start_time = update.start_time or event.start_time - new_duration = update.duration_min or event.duration_min - - # Проверка пересечений + if not event: + raise HTTPException(status_code=404, detail="Item not found") + + if event.repeat_weekly and update.scope == "one_date": + occurrence_date = update.occurrence_date or event.date + replacement_date = update.date or occurrence_date + replacement_start_time = update.start_time or event.start_time + replacement_duration = update.duration_min or event.duration_min + overlap = await check_event_overlap( - db, new_date, new_start_time, new_duration, item_id + db, + replacement_date, + replacement_start_time, + replacement_duration, + item_id, + occurrence_date=occurrence_date ) if overlap: raise HTTPException(status_code=400, detail="Нельзя изменить: пересечение по времени") - - if update.title: - event.title = update.title - if update.date: - event.date = update.date - if update.start_time: - event.start_time = update.start_time - if update.duration_min: - event.duration_min = update.duration_min - + + result = await db.execute( + select(EventException).where( + and_( + EventException.event_id == event.id, + EventException.date == occurrence_date + ) + ) + ) + for existing_exception in result.scalars().all(): + await db.delete(existing_exception) + + db.add(EventException( + event_id=event.id, + date=occurrence_date, + action="replace", + replacement_title=update.title or event.title, + replacement_date=replacement_date, + replacement_start_time=replacement_start_time, + replacement_duration_min=replacement_duration + )) await db.commit() - return {"success": True} - - raise HTTPException(status_code=404, detail="Item not found") + return {"success": True, "action": "exception_created"} + + new_date = update.date or event.date + new_start_time = update.start_time or event.start_time + new_duration = update.duration_min or event.duration_min + + overlap = await check_event_overlap(db, new_date, new_start_time, new_duration, item_id) + if overlap: + raise HTTPException(status_code=400, detail="Нельзя изменить: пересечение по времени") + + if update.title: + event.title = update.title + if update.date: + event.date = update.date + if event.repeat_weekly: + event.weekday = get_weekday_from_date(update.date) + if update.start_time: + event.start_time = update.start_time + if update.duration_min: + event.duration_min = update.duration_min + + await db.commit() + return {"success": True} @router.delete("/events/{item_id}") async def delete_item( item_id: int, scope: str = None, + occurrence_date: str = None, db: AsyncSession = Depends(get_db) ): - """Удалить task или event""" - # Пробуем найти как task + """Удалить task или event.""" result = await db.execute(select(Task).where(Task.id == item_id)) task = result.scalar_one_or_none() - + if task: - if task.repeat_weekly: - if scope == "one_date": - # Создаём исключение на удаление - weekday = get_weekday_from_date(task.date) - exception = WeeklyTaskException( - weekday=weekday, - date=task.date, - action="delete" + if task.repeat_weekly and scope == "one_date": + delete_date = occurrence_date or task.date + result = await db.execute( + select(WeeklyTaskException).where( + and_( + WeeklyTaskException.task_id == task.id, + WeeklyTaskException.date == delete_date + ) ) - db.add(exception) - await db.commit() - return {"success": True, "action": "exception_created"} - elif scope == "series": - # Удаляем все weekly tasks с этим weekday - await db.execute( - Task.__table__.delete().where(Task.weekday == task.weekday) - ) - await db.commit() - return {"success": True} - + ) + for existing_exception in result.scalars().all(): + await db.delete(existing_exception) + db.add(WeeklyTaskException( + task_id=task.id, + weekday=get_weekday_from_date(delete_date), + date=delete_date, + action="delete" + )) + await db.commit() + return {"success": True, "action": "exception_created"} + + if task.repeat_weekly and scope == "series": + result = await db.execute(select(WeeklyTaskException).where(WeeklyTaskException.task_id == task.id)) + for exception in result.scalars().all(): + await db.delete(exception) + await db.delete(task) await db.commit() return {"success": True} - - # Пробуем найти как event + result = await db.execute(select(Event).where(Event.id == item_id)) event = result.scalar_one_or_none() - - if event: - await db.delete(event) + if not event: + raise HTTPException(status_code=404, detail="Item not found") + + if event.repeat_weekly and scope == "one_date": + delete_date = occurrence_date or event.date + result = await db.execute( + select(EventException).where( + and_( + EventException.event_id == event.id, + EventException.date == delete_date + ) + ) + ) + for existing_exception in result.scalars().all(): + await db.delete(existing_exception) + db.add(EventException( + event_id=event.id, + date=delete_date, + action="delete" + )) await db.commit() - return {"success": True} - - raise HTTPException(status_code=404, detail="Item not found") + return {"success": True, "action": "exception_created"} + + if event.repeat_weekly and scope == "series": + result = await db.execute(select(EventException).where(EventException.event_id == event.id)) + for exception in result.scalars().all(): + await db.delete(exception) + + await db.delete(event) + await db.commit() + return {"success": True} @router.get("/backup") async def backup_database(): - """Создать backup базы данных""" - import shutil + """Создать backup базы данных.""" import os - from datetime import datetime - + import shutil + db_path = os.getenv("DATABASE_PATH", "data/schedule.db") backup_path = f"{db_path}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}" - + if os.path.exists(db_path): shutil.copy2(db_path, backup_path) return {"success": True, "backup_path": backup_path} return {"success": False, "error": "Database not found"} - diff --git a/backend/database.py b/backend/database.py index 5e46855..e1ab0e2 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,6 +1,6 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.orm import declarative_base, sessionmaker -from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text +from sqlalchemy.orm import declarative_base +from sqlalchemy import Column, Integer, String, Boolean, text import os Base = declarative_base() @@ -31,16 +31,33 @@ class Event(Base): start_time = Column(String) # HH:MM duration_min = Column(Integer, nullable=False) title = Column(String, nullable=False) + repeat_weekly = Column(Boolean, default=False) + weekday = Column(Integer) # 0=Monday, 6=Sunday, только для weekly events class WeeklyTaskException(Base): __tablename__ = "weekly_task_exceptions" id = Column(Integer, primary_key=True, index=True) + task_id = Column(Integer, index=True) weekday = Column(Integer, nullable=False) # 0=Monday, 6=Sunday date = Column(String, nullable=False) # YYYY-MM-DD action = Column(String, nullable=False) # "delete" или "replace" replacement_title = Column(String) # для action="replace" + replacement_date = Column(String) # для переноса/замены одного вхождения + + +class EventException(Base): + __tablename__ = "event_exceptions" + + id = Column(Integer, primary_key=True, index=True) + event_id = Column(Integer, index=True, nullable=False) + date = Column(String, nullable=False) + action = Column(String, nullable=False) # "delete" или "replace" + replacement_title = Column(String) + replacement_date = Column(String) + replacement_start_time = Column(String) + replacement_duration_min = Column(Integer) class TelegramUser(Base): @@ -53,6 +70,37 @@ class TelegramUser(Base): async def init_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await _run_migrations(conn) + + +async def _run_migrations(conn): + table_columns = await _get_table_columns(conn) + + task_columns = table_columns.get("tasks", set()) + if "weekday" not in task_columns: + await conn.execute(text("ALTER TABLE tasks ADD COLUMN weekday INTEGER")) + + event_columns = table_columns.get("events", set()) + if "repeat_weekly" not in event_columns: + await conn.execute(text("ALTER TABLE events ADD COLUMN repeat_weekly BOOLEAN DEFAULT 0")) + if "weekday" not in event_columns: + await conn.execute(text("ALTER TABLE events ADD COLUMN weekday INTEGER")) + + exception_columns = table_columns.get("weekly_task_exceptions", set()) + if "task_id" not in exception_columns: + await conn.execute(text("ALTER TABLE weekly_task_exceptions ADD COLUMN task_id INTEGER")) + if "replacement_date" not in exception_columns: + await conn.execute(text("ALTER TABLE weekly_task_exceptions ADD COLUMN replacement_date TEXT")) + + +async def _get_table_columns(conn): + table_names = ["tasks", "events", "weekly_task_exceptions", "event_exceptions"] + columns = {} + for table_name in table_names: + result = await conn.exec_driver_sql(f"PRAGMA table_info({table_name})") + rows = result.fetchall() + columns[table_name] = {row[1] for row in rows} + return columns async def get_db(): @@ -61,4 +109,3 @@ async def get_db(): yield session finally: await session.close() - diff --git a/backend/models.py b/backend/models.py index fc9eded..406dcde 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,7 +1,5 @@ from pydantic import BaseModel from typing import Optional, Literal -from datetime import date - class TaskCreate(BaseModel): date: str # YYYY-MM-DD title: str @@ -13,6 +11,7 @@ class EventCreate(BaseModel): start_time: str # HH:MM duration_min: int title: str + repeat_weekly: bool = False class TaskResponse(BaseModel): id: int @@ -28,11 +27,13 @@ class EventResponse(BaseModel): duration_min: int title: str kind: Literal["event"] = "event" + repeat_weekly: bool = False class ScheduleItem(BaseModel): kind: Literal["task", "event"] id: int date: str + source_date: Optional[str] = None title: str start_time: Optional[str] = None duration_min: Optional[int] = None @@ -46,5 +47,5 @@ class UpdateRequest(BaseModel): date: Optional[str] = None start_time: Optional[str] = None duration_min: Optional[int] = None + occurrence_date: Optional[str] = None scope: Optional[Literal["one_date", "series"]] = None # для weekly tasks - diff --git a/backend/utils.py b/backend/utils.py index 2f4d585..6d39c11 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -3,97 +3,182 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ import pytz -from backend.database import Task, Event, WeeklyTaskException +from backend.database import Task, Event, WeeklyTaskException, EventException from backend.models import ScheduleItem TZ = pytz.timezone("Europe/Moscow") def get_weekday_from_date(date_str: str) -> int: - """Получить день недели (0=Monday, 6=Sunday) из даты""" + """Получить день недели (0=Monday, 6=Sunday) из даты.""" dt = datetime.strptime(date_str, "%Y-%m-%d") return dt.weekday() def get_week_range(date_str: str = None): - """Получить диапазон дат недели (понедельник-воскресенье)""" + """Получить диапазон дат недели (понедельник-воскресенье).""" if date_str: base_date = datetime.strptime(date_str, "%Y-%m-%d").date() else: base_date = datetime.now(TZ).date() - - # Находим понедельник + days_since_monday = base_date.weekday() monday = base_date - timedelta(days=days_since_monday) sunday = monday + timedelta(days=6) - + return monday.strftime("%Y-%m-%d"), sunday.strftime("%Y-%m-%d") def format_date_russian(date_str: str) -> str: - """Форматировать дату в русском формате: 'Вторник, 2 декабря'""" + """Форматировать дату в русском формате: 'Вторник, 2 декабря'.""" dt = datetime.strptime(date_str, "%Y-%m-%d") - + weekdays = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"] months = [ "января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря" ] - + weekday = weekdays[dt.weekday()] month = months[dt.month - 1] - + return f"{weekday}, {dt.day} {month}" +def _time_to_minutes(value: str) -> int: + hour, minute = map(int, value.split(":")) + return hour * 60 + minute + + async def check_event_overlap( db: AsyncSession, date: str, start_time: str, duration_min: int, - exclude_id: int = None + exclude_id: int = None, + occurrence_date: str = None ) -> bool: - """Проверить пересечение по времени с другими events""" - # Парсим время начала - start_hour, start_min = map(int, start_time.split(":")) - start_minutes = start_hour * 60 + start_min + """Проверить пересечение по времени с разовыми и weekly events.""" + start_minutes = _time_to_minutes(start_time) end_minutes = start_minutes + duration_min - - # Получаем все events на эту дату - query = select(Event).where(Event.date == date) - if exclude_id: - query = query.where(Event.id != exclude_id) - - result = await db.execute(query) - events = result.scalars().all() - + + events = await materialize_events(db, date, date) + for event in events: - ev_start_hour, ev_start_min = map(int, event.start_time.split(":")) - ev_start_minutes = ev_start_hour * 60 + ev_start_min - ev_end_minutes = ev_start_minutes + event.duration_min - - # Проверка пересечения интервалов + if exclude_id and event.id == exclude_id and (occurrence_date is None or event.date == occurrence_date): + continue + + ev_start_minutes = _time_to_minutes(event.start_time) + ev_end_minutes = ev_start_minutes + (event.duration_min or 0) if not (end_minutes <= ev_start_minutes or start_minutes >= ev_end_minutes): return True - + return False +async def materialize_events( + db: AsyncSession, + from_date: str, + to_date: str +) -> list[ScheduleItem]: + items = [] + + result = await db.execute( + select(Event).where( + and_( + Event.date >= from_date, + Event.date <= to_date, + Event.repeat_weekly == False + ) + ) + ) + one_off_events = result.scalars().all() + for event in one_off_events: + items.append(ScheduleItem( + kind="event", + id=event.id, + date=event.date, + source_date=event.date, + title=event.title, + start_time=event.start_time, + duration_min=event.duration_min, + repeat_weekly=False + )) + + result = await db.execute(select(Event).where(Event.repeat_weekly == True)) + weekly_events = result.scalars().all() + if not weekly_events: + return items + + result = await db.execute( + select(EventException).where( + and_( + EventException.date >= from_date, + EventException.date <= to_date + ) + ) + ) + exceptions = result.scalars().all() + exception_dict = {(ex.event_id, ex.date): ex for ex in exceptions} + + from_dt = datetime.strptime(from_date, "%Y-%m-%d").date() + to_dt = datetime.strptime(to_date, "%Y-%m-%d").date() + current_date = from_dt + + while current_date <= to_dt: + date_str = current_date.strftime("%Y-%m-%d") + weekday = current_date.weekday() + + for event in weekly_events: + base_date = datetime.strptime(event.date, "%Y-%m-%d").date() + if current_date < base_date or event.weekday != weekday: + continue + + exception = exception_dict.get((event.id, date_str)) + if exception: + if exception.action == "delete": + continue + items.append(ScheduleItem( + kind="event", + id=event.id, + date=exception.replacement_date or date_str, + source_date=date_str, + title=exception.replacement_title or event.title, + start_time=exception.replacement_start_time or event.start_time, + duration_min=exception.replacement_duration_min or event.duration_min, + repeat_weekly=True + )) + continue + + items.append(ScheduleItem( + kind="event", + id=event.id, + date=date_str, + source_date=date_str, + title=event.title, + start_time=event.start_time, + duration_min=event.duration_min, + repeat_weekly=True + )) + + current_date += timedelta(days=1) + + return items + + async def materialize_weekly_tasks( db: AsyncSession, from_date: str, to_date: str ) -> list[ScheduleItem]: - """Материализовать weekly tasks в диапазоне дат""" - from backend.database import Task, WeeklyTaskException - + """Материализовать weekly tasks в диапазоне дат.""" items = [] - - # Получаем все weekly tasks + result = await db.execute(select(Task).where(Task.repeat_weekly == True)) weekly_tasks = result.scalars().all() - - # Получаем все исключения + if not weekly_tasks: + return items + result = await db.execute( select(WeeklyTaskException).where( and_( @@ -103,44 +188,52 @@ async def materialize_weekly_tasks( ) ) exceptions = result.scalars().all() - exception_dict = {(ex.date, ex.weekday): ex for ex in exceptions} - + exception_dict = {} + for ex in exceptions: + if ex.task_id is not None: + exception_dict[(ex.task_id, ex.date)] = ex + else: + exception_dict[(None, ex.date, ex.weekday)] = ex + from_dt = datetime.strptime(from_date, "%Y-%m-%d").date() to_dt = datetime.strptime(to_date, "%Y-%m-%d").date() - + current_date = from_dt while current_date <= to_dt: date_str = current_date.strftime("%Y-%m-%d") weekday = current_date.weekday() - + for task in weekly_tasks: - if task.weekday == weekday: - # Проверяем исключения - ex_key = (date_str, weekday) - if ex_key in exception_dict: - exception = exception_dict[ex_key] - if exception.action == "delete": - continue # Пропускаем эту дату - elif exception.action == "replace": - items.append(ScheduleItem( - kind="task", - id=task.id, - date=date_str, - title=exception.replacement_title, - repeat_weekly=True - )) - continue - - # Обычная weekly task + base_date = datetime.strptime(task.date, "%Y-%m-%d").date() + if current_date < base_date or task.weekday != weekday: + continue + + exception = exception_dict.get((task.id, date_str)) + if not exception: + exception = exception_dict.get((None, date_str, weekday)) + + if exception: + if exception.action == "delete": + continue items.append(ScheduleItem( kind="task", id=task.id, - date=date_str, - title=task.title, + date=exception.replacement_date or date_str, + source_date=date_str, + title=exception.replacement_title or task.title, repeat_weekly=True )) - - current_date += timedelta(days=1) - - return items + continue + items.append(ScheduleItem( + kind="task", + id=task.id, + date=date_str, + source_date=date_str, + title=task.title, + repeat_weekly=True + )) + + current_date += timedelta(days=1) + + return items diff --git a/frontend/admin/index.html b/frontend/admin/index.html index c7fd182..8e51da9 100644 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -3,37 +3,58 @@ - Админка - Расписание - + Админка расписания + -
-
-

Админка расписания

-
- - - +
+
+
+

Schedule Studio

+

Редактор расписания

+

Планируйте задачи и занятия на несколько недель вперед, меняйте серии целиком и переносите встречи прямо мышкой по сетке времени.

+
+
+ + +
- -
- - + +
+
+ +
+
Видимый диапазон
+
+
+ +
+
+ Задачи + Занятия + Цикличные +
+
+ +
+ Клик по дню добавляет занятие. + `Ctrl/Cmd + клик` открывает создание задачи. + Перетаскивайте карточки занятий на новое время или другой день. +
+ +
+
- -
- - + - - + + - diff --git a/frontend/admin/static/script.js b/frontend/admin/static/script.js index c6833d4..b1a3d61 100644 --- a/frontend/admin/static/script.js +++ b/frontend/admin/static/script.js @@ -1,13 +1,20 @@ -let currentWeekStart = null; +const MOSCOW_TIMEZONE = 'Europe/Moscow'; +const WEEKDAYS = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']; +const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']; +const VIEW_WEEKS = 4; +const SLOT_MINUTES = 15; +const START_HOUR = 8; +const END_HOUR = 20; +const PIXELS_PER_MINUTE = 0.8; -const weekdays = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']; -const weekdaysShort = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; +let currentRangeStart = null; +let scheduleItems = []; +let itemIndex = new Map(); -function getTodayInLondon() { - // Получаем текущую дату в таймзоне London +function getMoscowDateString() { const now = new Date(); const formatter = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Europe/Moscow', + timeZone: MOSCOW_TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit' @@ -15,227 +22,41 @@ function getTodayInLondon() { return formatter.format(now); } -function getWeekStart(dateStr = null) { - if (!dateStr) { - dateStr = getTodayInLondon(); - } - // Создаем дату из строки YYYY-MM-DD в локальном времени - const [year, month, day] = dateStr.split('-').map(Number); - const date = new Date(year, month - 1, day); - // getDay() возвращает 0=воскресенье, 1=понедельник, ..., 6=суббота - // Находим понедельник текущей недели - const dayOfWeek = date.getDay(); - // Если воскресенье (0), идем на 6 дней назад, иначе на (dayOfWeek-1) дней назад - const diff = dayOfWeek === 0 ? -6 : -(dayOfWeek - 1); - const monday = new Date(date); - monday.setDate(date.getDate() + diff); - // Проверяем, что получился понедельник - if (monday.getDay() !== 1) { - console.error('Error: getWeekStart did not return Monday!', monday.getDay()); - } - // Возвращаем в формате YYYY-MM-DD - const yearStr = monday.getFullYear(); - const monthStr = String(monday.getMonth() + 1).padStart(2, '0'); - const dayStr = String(monday.getDate()).padStart(2, '0'); - return `${yearStr}-${monthStr}-${dayStr}`; +function parseISODate(value) { + const [year, month, day] = value.split('-').map(Number); + return new Date(year, month - 1, day); } -function formatDate(dateStr) { - // Создаем дату из строки YYYY-MM-DD в локальном времени - const [year, month, day] = dateStr.split('-').map(Number); - const date = new Date(year, month - 1, day); - // getDay() возвращает 0=воскресенье, 1=понедельник, ..., 6=суббота - // Конвертируем в формат где 0=понедельник, 6=воскресенье - const jsDay = date.getDay(); - const weekdayIndex = jsDay === 0 ? 6 : jsDay - 1; // 0=пн, 6=вс - return `${weekdaysShort[weekdayIndex]}, ${date.getDate()}`; +function formatISODate(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } -function formatDateFull(dateStr) { - // Создаем дату из строки YYYY-MM-DD в локальном времени - const [year, month, day] = dateStr.split('-').map(Number); - const date = new Date(year, month - 1, day); - const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', - 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']; - // getDay() возвращает 0=воскресенье, 1=понедельник, ..., 6=суббота - // Конвертируем в формат где 0=понедельник, 6=воскресенье - const jsDay = date.getDay(); - const weekdayIndex = jsDay === 0 ? 6 : jsDay - 1; // 0=пн, 6=вс - return `${weekdays[weekdayIndex]}, ${date.getDate()} ${months[date.getMonth()]}`; +function addDays(dateStr, days) { + const date = parseISODate(dateStr); + date.setDate(date.getDate() + days); + return formatISODate(date); } -function getWeekDates(startDate) { - // startDate должен быть понедельником (в формате YYYY-MM-DD) - const dates = []; - // Создаем дату из строки в локальном времени - const [year, month, day] = startDate.split('-').map(Number); - const start = new Date(year, month - 1, day); - // Убеждаемся, что это понедельник - const dayOfWeek = start.getDay(); - if (dayOfWeek !== 1) { - // Если не понедельник, находим понедельник - const diff = dayOfWeek === 0 ? -6 : -(dayOfWeek - 1); - start.setDate(start.getDate() + diff); - } - // Возвращаем 7 дней: понедельник - воскресенье - const baseDate = new Date(start); - for (let i = 0; i < 7; i++) { - const date = new Date(baseDate); - date.setDate(baseDate.getDate() + i); - // Форматируем в YYYY-MM-DD - const yearStr = date.getFullYear(); - const monthStr = String(date.getMonth() + 1).padStart(2, '0'); - const dayStr = String(date.getDate()).padStart(2, '0'); - dates.push(`${yearStr}-${monthStr}-${dayStr}`); - } - // Проверяем, что первый день - понедельник, последний - воскресенье - const [firstYear, firstMonth, firstDayNum] = dates[0].split('-').map(Number); - const firstDate = new Date(firstYear, firstMonth - 1, firstDayNum); - const [lastYear, lastMonth, lastDayNum] = dates[6].split('-').map(Number); - const lastDate = new Date(lastYear, lastMonth - 1, lastDayNum); - const firstDay = firstDate.getDay(); - const lastDay = lastDate.getDay(); - if (firstDay !== 1 || lastDay !== 0) { - console.error('Error: Week should start on Monday and end on Sunday!', { - first: dates[0], firstDay, - last: dates[6], lastDay - }); - } - return dates; +function startOfWeek(dateStr = null) { + const base = parseISODate(dateStr || getMoscowDateString()); + const jsDay = base.getDay(); + const diff = jsDay === 0 ? -6 : 1 - jsDay; + base.setDate(base.getDate() + diff); + return formatISODate(base); } -async function loadSchedule() { - // Убеждаемся, что currentWeekStart - это понедельник - if (!currentWeekStart) { - currentWeekStart = getWeekStart(); - } else { - currentWeekStart = getWeekStart(currentWeekStart); - } - - const weekDates = getWeekDates(currentWeekStart); - const fromDate = weekDates[0]; - const toDate = weekDates[6]; - - // Проверяем, что неделя начинается с понедельника и заканчивается воскресеньем - const [mondayYear, mondayMonth, mondayDay] = weekDates[0].split('-').map(Number); - const monday = new Date(mondayYear, mondayMonth - 1, mondayDay); - const [sundayYear, sundayMonth, sundayDay] = weekDates[6].split('-').map(Number); - const sunday = new Date(sundayYear, sundayMonth - 1, sundayDay); - if (monday.getDay() !== 1 || sunday.getDay() !== 0) { - console.error('Week should start on Monday and end on Sunday! Fixing...', { - monday: weekDates[0], mondayDay: monday.getDay(), - sunday: weekDates[6], sundayDay: sunday.getDay() - }); - // Исправляем автоматически без рекурсии - currentWeekStart = getWeekStart(); - const correctedWeekDates = getWeekDates(currentWeekStart); - // Используем исправленные даты напрямую - const correctedFromDate = correctedWeekDates[0]; - const correctedToDate = correctedWeekDates[6]; - - try { - const response = await fetch(`/api/schedule?from_date=${correctedFromDate}&to_date=${correctedToDate}`); - const data = await response.json(); - renderSchedule(correctedWeekDates, data.items); - document.getElementById('week-range').textContent = - `${formatDateFull(correctedWeekDates[0])} - ${formatDateFull(correctedWeekDates[6])}`; - updateWeekNavigationButtons(); - } catch (error) { - console.error('Error loading schedule:', error); - } - return; - } - - try { - const response = await fetch(`/api/schedule?from_date=${fromDate}&to_date=${toDate}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - - if (!data.items) { - console.error('Invalid response format:', data); - return; - } - - renderSchedule(weekDates, data.items); - - // Обновляем заголовок недели - document.getElementById('week-range').textContent = - `${formatDateFull(weekDates[0])} - ${formatDateFull(weekDates[6])}`; - - // Обновляем кнопки навигации - updateWeekNavigationButtons(); - } catch (error) { - console.error('Error loading schedule:', error); - } +function getVisibleDates() { + return Array.from({ length: VIEW_WEEKS * 7 }, (_, index) => addDays(currentRangeStart, index)); } -function renderSchedule(weekDates, items) { - const grid = document.getElementById('schedule-grid'); - if (!grid) { - console.error('Schedule grid not found!'); - return; - } - grid.innerHTML = ''; - - weekDates.forEach(dateStr => { - const dayItems = items.filter(item => item.date === dateStr); - - const column = document.createElement('div'); - column.className = 'day-column'; - - column.innerHTML = ` -
${formatDateFull(dateStr)}
-
- ${renderDayItems(dayItems)} -
- `; - - grid.appendChild(column); - }); -} - -function renderDayItems(items) { - if (items.length === 0) { - return '
Нет записей
'; - } - - const tasks = items.filter(item => item.kind === 'task'); - const events = items.filter(item => item.kind === 'event'); - - let html = ''; - - // Tasks - tasks.forEach(task => { - html += ` -
-
${task.title}
- ${task.repeat_weekly ? '
Повторяется
' : ''} -
- - -
-
- `; - }); - - // Events - events.sort((a, b) => a.start_time.localeCompare(b.start_time)).forEach(event => { - const endTime = calculateEndTime(event.start_time, event.duration_min); - html += ` -
-
${event.start_time}-${endTime}
-
${event.title}
-
- - -
-
- `; - }); - - return html; +function formatDayLabel(dateStr) { + const date = parseISODate(dateStr); + const weekday = WEEKDAYS[(date.getDay() + 6) % 7]; + return { + title: `${weekday}, ${date.getDate()} ${MONTHS[date.getMonth()]}`, + short: weekday.slice(0, 2), + monthDay: `${date.getDate()} ${MONTHS[date.getMonth()]}` + }; } function calculateEndTime(startTime, durationMin) { @@ -246,6 +67,268 @@ function calculateEndTime(startTime, durationMin) { return `${String(endHour).padStart(2, '0')}:${String(endMinute).padStart(2, '0')}`; } +function timeToMinutes(time) { + const [hour, minute] = time.split(':').map(Number); + return hour * 60 + minute; +} + +function minutesToTime(totalMinutes) { + const hour = Math.floor(totalMinutes / 60); + const minute = totalMinutes % 60; + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + +function rebuildItemIndex() { + itemIndex = new Map(); + scheduleItems.forEach((item) => { + itemIndex.set(`${item.kind}:${item.id}:${item.source_date || item.date}`, item); + }); +} + +async function loadSchedule() { + if (!currentRangeStart) { + currentRangeStart = startOfWeek(); + } + + const fromDate = currentRangeStart; + const toDate = addDays(currentRangeStart, VIEW_WEEKS * 7 - 1); + + const response = await fetch(`/api/schedule?from_date=${fromDate}&to_date=${toDate}`); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Не удалось загрузить расписание'); + } + + const data = await response.json(); + scheduleItems = data.items || []; + rebuildItemIndex(); + renderSchedule(); +} + +function renderSchedule() { + const grid = document.getElementById('schedule-grid'); + const dates = getVisibleDates(); + const today = getMoscowDateString(); + + grid.innerHTML = dates.map((dateStr) => { + const dayItems = scheduleItems.filter((item) => item.date === dateStr); + const tasks = dayItems.filter((item) => item.kind === 'task'); + const events = dayItems + .filter((item) => item.kind === 'event') + .sort((a, b) => a.start_time.localeCompare(b.start_time)); + const label = formatDayLabel(dateStr); + const weekNumber = Math.floor(dates.indexOf(dateStr) / 7) + 1; + + return ` +
+
+
+

${label.title}

+
Неделя ${weekNumber} в текущем окне
+
+
${dateStr === today ? 'Сегодня' : label.short}
+
+ +
+ +
+ ${tasks.map(renderTaskCard).join('')} +
+
+ + + ${renderTimeline(dateStr, events)} +
+ `; + }).join(''); + + document.getElementById('week-range').textContent = `${formatDayLabel(dates[0]).title} - ${formatDayLabel(dates[dates.length - 1]).title}`; + bindInteractiveHandlers(); +} + +function renderTaskCard(task) { + return ` +
+
${escapeHtml(task.title)}
+
${task.repeat_weekly ? 'Цикличная задача, обновления можно применить ко всей серии.' : 'Разовая задача.'}
+
+ + +
+
+ `; +} + +function renderTimeline(dateStr, events) { + const hours = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, index) => START_HOUR + index); + + return ` +
+
+ ${hours.map((hour, index) => ` +
+ ${String(hour).padStart(2, '0')}:00 +
+ `).join('')} + ${events.map((event) => renderEventBlock(event)).join('')} +
+
+ `; +} + +function renderEventBlock(event) { + const startMinutes = timeToMinutes(event.start_time) - START_HOUR * 60; + const top = Math.max(0, startMinutes * PIXELS_PER_MINUTE); + const height = Math.max(42, event.duration_min * PIXELS_PER_MINUTE); + const endTime = calculateEndTime(event.start_time, event.duration_min); + return ` +
+
${event.start_time} - ${endTime}
+
${escapeHtml(event.title)}
+
${event.repeat_weekly ? 'Цикличное занятие' : 'Разовое занятие'}
+
+ + +
+
+ `; +} + +function bindInteractiveHandlers() { + document.removeEventListener('click', clearActiveItems); + document.addEventListener('click', clearActiveItems); + + document.querySelectorAll('[data-action="edit"]').forEach((button) => { + button.addEventListener('click', (event) => { + event.stopPropagation(); + openEditModal(button.dataset.kind, Number(button.dataset.id), button.dataset.date); + }); + }); + + document.querySelectorAll('[data-action="delete"]').forEach((button) => { + button.addEventListener('click', (event) => { + event.stopPropagation(); + deleteItem(button.dataset.kind, Number(button.dataset.id), button.dataset.date); + }); + }); + + document.querySelectorAll('.task-item').forEach((item) => { + item.addEventListener('click', (event) => { + event.stopPropagation(); + toggleActiveItem(item); + }); + }); + + document.querySelectorAll('.event-block').forEach((block) => { + block.addEventListener('click', (event) => { + event.stopPropagation(); + toggleActiveItem(block); + }); + }); + + document.querySelectorAll('.day-card').forEach((card, index) => { + card.addEventListener('click', (event) => { + if (event.target.closest('button, .event-block, .task-item')) { + return; + } + const targetDate = getVisibleDates()[index]; + if (event.ctrlKey || event.metaKey) { + showTaskModal({ date: targetDate }); + return; + } + showEventModal({ date: targetDate }); + }); + }); + + document.querySelectorAll('.event-block').forEach((block) => { + block.addEventListener('dragstart', handleDragStart); + block.addEventListener('dragend', () => block.classList.remove('dragging')); + }); + + document.querySelectorAll('.timeline-dropzone').forEach((zone) => { + zone.addEventListener('dragover', (event) => event.preventDefault()); + zone.addEventListener('drop', handleEventDrop); + }); +} + +function clearActiveItems() { + document.querySelectorAll('.task-item.is-active, .event-block.is-active').forEach((item) => { + item.classList.remove('is-active'); + }); +} + +function toggleActiveItem(element) { + const isActive = element.classList.contains('is-active'); + clearActiveItems(); + if (!isActive) { + element.classList.add('is-active'); + } +} + +function handleDragStart(event) { + const block = event.currentTarget; + block.classList.add('dragging'); + event.dataTransfer.setData('text/plain', JSON.stringify({ + id: Number(block.dataset.id), + occurrenceDate: block.dataset.date + })); +} + +async function handleEventDrop(event) { + event.preventDefault(); + const raw = event.dataTransfer.getData('text/plain'); + if (!raw) { + return; + } + + const payload = JSON.parse(raw); + const item = itemIndex.get(`event:${payload.id}:${payload.occurrenceDate}`); + if (!item) { + return; + } + + const rect = event.currentTarget.getBoundingClientRect(); + const relativeY = Math.max(0, Math.min(rect.height, event.clientY - rect.top)); + const minutesFromStart = Math.round((relativeY / PIXELS_PER_MINUTE) / SLOT_MINUTES) * SLOT_MINUTES; + const clampedMinutes = Math.min((END_HOUR - START_HOUR) * 60 - item.duration_min, Math.max(0, minutesFromStart)); + const newTime = minutesToTime(START_HOUR * 60 + clampedMinutes); + const newDate = event.currentTarget.dataset.dropDate; + + let scope = null; + if (item.repeat_weekly) { + scope = promptRecurringScope('Переместить только это занятие или всю серию?', true); + if (!scope) { + return; + } + } + + await saveItemUpdate('event', item.id, { + occurrence_date: item.source_date || item.date, + date: newDate, + start_time: newTime, + duration_min: item.duration_min, + scope + }); +} + +function promptRecurringScope(message, allowCancel = false) { + const answer = window.prompt(`${message}\nВведите "1" для одного вхождения, "2" для всей серии.${allowCancel ? ' Оставьте пустым для отмены.' : ''}`, '2'); + if (!answer && allowCancel) { + return null; + } + if (answer === '1') { + return 'one_date'; + } + return 'series'; +} + function showModal(content) { document.getElementById('modal-body').innerHTML = content; document.getElementById('modal').style.display = 'block'; @@ -255,388 +338,265 @@ function closeModal() { document.getElementById('modal').style.display = 'none'; } -function showAddTaskModal(selectedDate = null) { - const today = getTodayInLondon(); - const todayWeekStart = getWeekStart(today); - const nextWeekStart = new Date(todayWeekStart + 'T00:00:00'); - nextWeekStart.setDate(nextWeekStart.getDate() + 7); - const nextWeekStartStr = nextWeekStart.toISOString().split('T')[0]; - - // Ограничиваем выбор дат: только текущая и следующая неделя - const minDate = todayWeekStart; - const maxDate = new Date(nextWeekStartStr + 'T00:00:00'); - maxDate.setDate(maxDate.getDate() + 6); // Воскресенье следующей недели - const maxDateStr = maxDate.toISOString().split('T')[0]; - - const selectedDateObj = selectedDate ? new Date(selectedDate + 'T00:00:00') : new Date(today + 'T00:00:00'); - // getDay() возвращает 0=воскресенье, 1=понедельник, ..., 6=суббота - // Конвертируем в формат 0=понедельник, 6=воскресенье - const jsWeekday = selectedDateObj.getDay(); - const selectedWeekday = jsWeekday === 0 ? 6 : jsWeekday - 1; // 0=пн, 6=вс - const remainingWeekdays = []; - - // Оставшиеся дни недели после выбранного (среда=2, четверг=3, пятница=4, суббота=5, воскресенье=6) - for (let i = selectedWeekday + 1; i <= 6; i++) { - remainingWeekdays.push(i); +function buildScopeSelector(item) { + if (!item.repeat_weekly) { + return ''; } - - const content = ` -

Добавить задачу

-
-
- - + return ` +
+ +
+ +
-
- - -
-
-
- - +
+ `; +} + +function showTaskModal(initial = {}, item = null) { + const isEdit = Boolean(item); + showModal(` + + + +
+
+ +
-
-