Редактор еженедельных расписаний

    Пишу, потому что третий раз за год сталкиваюсь с этой задачей. Каждый раз все начинается с удивительно-креативного решения попроще, а в конце приходит к той системе, о которой расскажу.

    Задача — создание и поддержание еженедельного расписания, такого как расписание уроков в школе или расписание работы врачей и чиновников. Имеется набор слотов, каждый слот — это место в недельном расписании с различными дополнительными параметрами, такими как номер кабинета, имя сотрудника. Требуется построить гибкую систему с полной историей, способную решать задачи типа: создать другое расписание с начала лета, заменить учителя на ближайшие 3 недели, передвинуть расписание с пятницы на субботу из-за праздника.

    Напишу, обо что обычно спотыкаются и как это решить, решу задачку о закрашивании полоски, а затем приведу примеры простого бэкенда на node/sequelize и закончу несложным фронтендом на vue/vuex/vuetify/nuxt, где можно будет все это потаскать мышкой и посмотреть, как работает.

    Коды выложены на github, развернуто здесь.



    Гранулярные изменения


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


    При сохранении обновляются все данные слота, история теряется. Попробуем добавить такой элемент:


    Опять мимо. Допустим, 4-ого июня в понедельник был зафиксирован однодневный переезд занятия из первого кабинета во второй. Затем приходит новое требование — с 28 мая занятие всегда будет начинаться в 20:00 вместо 19:00. Открываем форму, меняем время, указываем дату с 28-ого и навсегда и… все поля, вместе с номером кабинета, уходят на сервер. Временное изменение 4-ого июня перезатирается. По данной форме невозможно определить, какие именно поля на каких интервалах пользователь хочет изменить, потому что отправляются вообще все поля.

    Идея в том, чтобы каждое правило менялось независимо от других со своим интервалом. Слот задается набором одномерных параметров, каждый параметр имеет историю изменений, заданную набором правил. Каждое правило содержит значение, дату начала и конца. Так как это недельный календарь, даты достаточно указывать с точностью до недели, YYYYWW.


    Может показаться, что редактирование слота теперь сильно усложнено — чтобы изменить несколько полей, нужно каждое поле выбрать, открыть форму, проставить значение и интервал. Однако на практике изменение нескольких полей оказалось редкой ситуацией. Гораздо более частая — bulk-update нескольких слотов за раз. Например, чтобы проставить отсутствие учителя по болезни, нужно выбрать все его блоки, проставить staff assignment статус в medical leave, а затем для тех же блоков выбрать замещающего учителя. Всего 2 действия вместо n действий для n слотов в случае, как в случае если бы они задавались через традиционную форму. В системе StarBright.com, над которой я сейчас работаю, это выглядит так:


    Задачка о закрашивании полоски


    Расмотрим полоску, состоящую из клеточек, покрашенных в разные цвета. Каждая клеточка — неделя, каждый цвет — значение. Приходит новый цвет и интервал, в котором его применить, нужно им перекрасить поверх то что есть. На уровне данных это означает, что нужно удалить полностью перекрытые интервалы, изменить интервалы частично перекрытых, добавить новый интервал, слить соседние одноцветные интервалы в один. Финальный результат должен состоять из интервалов, которые не перекрываются.


    Результат: [{delete, id: 2}, {update, id: 1, data: {to: 5}}, {update, id: 3, data: {from: 16}}, {insert, data: {from: 6, to: 15, value: wed}}]

    Это простая задачка, но тут легко что-то не учесть. Здесь находится отдельный репозиторий с решением и тестами. http://timeblock-rules.rag.lt — здесь можно проверить, как он работает, и поиграть с закрашиванием.

    Бэкенд


    Правила не перекрываются, поэтому достаточно простейшего `select * from rules where from<=:week and (to is null or to>=:week)`, чтобы выбрать ровно нужные правила для указанной недели. Здесь находится простой пример бэкенда на node/sequelize. Там используется комбинированный стиль c promises и async/await, о котором можно почитать в другой моей статье.

    Вот action, выбирающий правила для указанной недели:
    routes.get('/timeblocks', async (req, res) => {
      try {
        ... validation ...
        await Rule
          .findAll({
            where: {
              from: {$or: [{$lte: req.query.week}, null]},
              to: {$or: [{$gte: req.query.week}, null]}
            }
          })
          .then(
            sendSuccess(res, 'Calendar data extracted.'),
            throwError(500, 'sequelize error')
          )
      } catch (error) { catchError(res, error) }
    })
    


    А вот — PATCH для изменения набора правил:
    routes.patch('/timeblocks/:id(\\d+)', async (req, res) => {
      try {
        ... validation ...
        const initialRules = await Rule
          .findAll({
            where: {
              timeblock_id: req.params.id,
              type: {$in: req.params.rules.map(rule => rule.type)}
            }
          }).catch(throwError(500, 'sequelize error'))
        const promises = []
        req.params.rules.forEach(rule => {
          // This function defined in stripe coloring repo, https://github.com/Kasheftin/timeblock-rules/blob/master/src/fn/rules.js;
          const actions = processNewRule(rule, initialRules[rule.type] || [])
          actions.forEach(action => {
            if (action.type === 'delete') {
              promises.push(Rule.destroy({where: {id: action.id}}))
            } else if (action.type === 'update') {
              promises.push(Rule.update(action.data, {where: {id: action.id}}))
            } else if (action.type === 'insert') {
              promises.push(Rule.build({...action.data, timeblock_id: rule.timeblock_id, type: rule.type}).save())
            }
          })
        })
        Promise.all(promises).then(
          result => sendSuccess(res, 'Timeblock rules updated.')()
        )
      } catch (error) { catchError(res, error) }
    })
    


    Это — самая сложная идейная часть бэкенда, остальное еще проще.

    Возникает вопрос, как удалять слоты. В данном случае хранится полная история, ничего не удаляется. Есть поле статуса, которое может быть opened, temporary closed и closed. Посетители видят активные слоты и временно неактивные, на последних обычно админ обычно пишет комментарий, почему нет занятия. Closed-слотов со временем становится много, и, чтобы упростить ситуацию, полезно ввести еще одно свойство типа учебного года, показывать при редактировании слоты только текущего учебного года.

    Фронтенд


    Код находится в этом репозитории, это простой одностраничный сайт на nuxt. Вообще-то с ssr есть несколько заморочек (например, здесь подробно разбираю, как написать аутидентификацию на nuxt), но простые приложения на нем очень быстро пишутся.

    Вот код единственной страницы:
    export default {
      components: {...},
      fetch ({app, route, redirect, store}) {
        if (!route.query.week) {
          const newRoute = app.router.resolve({query: {...route.query, week: moment().format('YYYYWW')}}, route)
          return redirect(newRoute.href)
        }
        return Promise.resolve()
          .then(() => store.dispatch('calendar/set', {week: route.query.week}))
          .then(() => store.dispatch('calendar/fetch'))
      },
      computed: {
        week () { return this.$store.state.calendar.week }
      },
      watch: {
        week (week) {
          this.$router.push({
            query: {
              ...this.$route.query,
              week
            }
          })
          this.$store.dispatch('calendar/fetch')
        }
      }
    }
    


    Метод fetch работает на сервере и клиенте, делает редирект на текущую неделю и запрашивает календарь. При изменении недели идет перезапрос данных.

    Что делать с накладывающимися слотами? Ответ зависит от бизнес-логики, например, может понадобится валидация на сервере, которая запрещает наложения. В данном случае наложения допускаются, а чтобы получилась красивая картинка, такие слоты рисуются половинной ширины рядом друг с другом. Добавим верстку и получим такой вид:



    Все остальное — обычный javascript без особых идей. По mousedown на блоке начинается перетаскивание. События mousemove и mouseup навешиваются на все окно. Перетаскивание начинается с задержкой 200ms для того, чтобы отличить drag от клика. Параметры контейнеров, в которые отслеживается drop, просчитаны заранее, потому что getBoundingClientRect — слишком тяжелая операция для того, чтобы ее делать на каждый mousemove. Формы пришлось сделать две — одну для создания (простановка всех правил за раз начиная с текущей недели), другую — для гранулярных изменений слота.

    http://calendar.rag.lt — здесь можно проверить как все работает.

    Ссылки к статье


    • +10
    • 5,4k
    • 6
    Поделиться публикацией

    Похожие публикации

    Комментарии 6
      0

      Спасибо

        0
        Было б неплохо заиметь такое под винду и с оповещениями
          0
          … а также быстрым созданием записей (горячая клавиша — ввод текстовой строкой в определенном формате — энтер — запись готова), и с синхронизацией с гуглокалендарем.

          ЗЫ Cabinet — это такой шкафчик. Кабинет по-англицки будет Room.
            0
            Согласен, пример собран из не английского исходника. Почему-то в процессе kabinet в cabinet превратился… В проектах, над которыми работаю, скорость ввода не сильно важна. На первом месте — мощность отображения, наложение любых календарей друг на друга (например, календарь учителя поверх календаря activities), потом подсветка assignments и workshifts других учителей, чтобы было видно, когда данному можно проставить перерыв, на какие блоки его еще можно записать итд.
            0
            Тоже возникали такие задачи, ни разу до ума не довёл. А тут готовое и с кодом.
            Спасибо!
              +1
              Чуть-чуть занимался расписаниями, писал на c# прототип который по ген алгоритмы ищет оптимальное расписание для врачей. Было бы здорово прикрутить подобную возможность к этой штуке =)

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое