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

в 9:20, , рубрики: javascript, nodejs, nuxtjs, vuejs, vuex, Программирование

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Результат: [{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 работает на сервере и клиенте, делает редирект на текущую неделю и запрашивает календарь. При изменении недели идет перезапрос данных.

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

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

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

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

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

Автор: Kasheftin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js