Skip to content

Commit

Permalink
feat(bot): implement trainings showing and check-in
Browse files Browse the repository at this point in the history
  • Loading branch information
evermake committed Mar 17, 2024
1 parent 8f6a64a commit f396df9
Show file tree
Hide file tree
Showing 12 changed files with 510 additions and 9 deletions.
3 changes: 3 additions & 0 deletions backend/src/bot/handlers/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export default handler((composer) => {
composer.use(views.main)
composer.use(views.settings)
composer.use(views.settingsLanguage)
composer.use(views.trainingsDaysList)
composer.use(views.trainingsDayTrainings)
composer.use(views.trainingsTraining)

composer.use(commands.start)
composer.use(commands.howgoodami)
Expand Down
6 changes: 6 additions & 0 deletions backend/src/bot/handlers/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { Context, MiddlewareFn } from 'grammy'
import main from './main'
import settings from './settings'
import settingsLanguage from './settings_language'
import trainingsDaysList from './trainings_days-list'
import trainingsDayTrainings from './trainings_day-trainings'
import trainingsTraining from './trainings_training'
import type { MessageContent } from '~/bot/plugins/message-sending'

// eslint-disable-next-line ts/ban-types
Expand All @@ -14,4 +17,7 @@ export default {
main,
settings,
settingsLanguage,
trainingsDaysList,
trainingsDayTrainings,
trainingsTraining,
}
22 changes: 20 additions & 2 deletions backend/src/bot/handlers/views/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@ const settingsButton = new Button({
payloadDecoder: () => null,
})

const trainingsButton = new Button({
id: `${VIEW_ID}:trainings`,
payloadEncoder: () => '',
payloadDecoder: () => null,
})

export default {
render: async (ctx) => {
const keyboard = new InlineKeyboard()
.text(ctx.t['Views.Buttons.Settings'], settingsButton.createCallbackData(null))
.text(ctx.t['Views.Main.Buttons.Settings'], settingsButton.createCallbackData(null))
.text(ctx.t['Views.Main.Buttons.Trainings'], trainingsButton.createCallbackData(null))

return {
type: 'text',
text: ctx.t['Views.Main.Text'],
text: ctx.t['Views.Main.Message'],
keyboard: keyboard,
}
},
Expand All @@ -37,6 +44,17 @@ export default {
})
})

composer
.filter(trainingsButton.filter)
.use(async (ctx) => {
ctx.answerCallbackQuery()
await ctx.editMessage({
chatId: ctx.chat!.id,
messageId: ctx.callbackQuery.message!.message_id,
content: await views.trainingsDaysList.render(ctx, {}),
})
})

return composer.middleware()
},
} satisfies View<Ctx> as View<Ctx>
2 changes: 1 addition & 1 deletion backend/src/bot/handlers/views/settings_language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default {

return {
type: 'text',
text: ctx.t['Views.LanguageSettings.Text'],
text: ctx.t['Views.LanguageSettings.Message'],
keyboard: keyboard,
}
},
Expand Down
109 changes: 109 additions & 0 deletions backend/src/bot/handlers/views/trainings_day-trainings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Composer, InlineKeyboard } from 'grammy'
import type { View } from '.'
import views from '.'
import type { Ctx } from '~/bot/context'
import { Button } from '~/utils/buttons'
import { getDateDayInTimezone, getDayBoundaries } from '~/utils/dates'
import { TIMEZONE } from '~/constants'

const VIEW_ID = 'trainings/days-list'

const backButton = new Button({
id: `${VIEW_ID}:back`,
payloadEncoder: () => '',
payloadDecoder: () => null,
})

const trainingButton = new Button<{ trainingId: number }>({
id: `${VIEW_ID}:training`,
payloadEncoder: ({ trainingId }) => trainingId.toString(),
payloadDecoder: data => ({ trainingId: Number.parseInt(data) }),
})

export type Props = {
date: Date
}

export default {
render: async (ctx, { date }) => {
const { year, month, day } = getDateDayInTimezone(date, TIMEZONE)
const [from, to] = getDayBoundaries({
year: year,
month: month,
day: day,
timezone: TIMEZONE,
})

const trainings = await ctx.domain.getTrainingsForUser({
telegramId: ctx.from!.id,
from: from,
to: to,
})

const keyboard = new InlineKeyboard()
trainings
.sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime())
.forEach((training) => {
const timeStart = training.startsAt.toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: TIMEZONE,
})
const timeEnd = training.endsAt.toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: TIMEZONE,
})
const statusEmoji = training.checkedIn
? '🟢'
: training.checkInAvailable
? '🔵'
: '🔴'

const data = trainingButton.createCallbackData({ trainingId: training.id })
keyboard.text(`${statusEmoji} ${timeStart}${timeEnd}`, data)
keyboard.text(training.title, data)
keyboard.row()
})
keyboard.text(ctx.t['Buttons.Back'], backButton.createCallbackData(null))

return {
type: 'text',
text: ctx.t['Views.DayTrainings.Message'],
keyboard: keyboard,
}
},
middleware: () => {
const composer = new Composer<Ctx>()

composer
.filter(trainingButton.filter)
.use(async (ctx) => {
const training = await ctx.domain.getTrainingForUser({
telegramId: ctx.from!.id,
trainingId: ctx.payload.trainingId,
})
ctx.answerCallbackQuery()
await ctx.editMessage({
chatId: ctx.chat!.id,
messageId: ctx.callbackQuery.message!.message_id,
content: await views.trainingsTraining.render(ctx, { training }),
})
})

composer
.filter(backButton.filter)
.use(async (ctx) => {
await ctx.editMessage({
chatId: ctx.chat!.id,
messageId: ctx.callbackQuery.message!.message_id,
content: await views.trainingsDaysList.render(ctx, {}),
})
ctx.answerCallbackQuery()
})

return composer.middleware()
},
} satisfies View<Ctx, Props> as View<Ctx, Props>
82 changes: 82 additions & 0 deletions backend/src/bot/handlers/views/trainings_days-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Composer, InlineKeyboard } from 'grammy'
import type { View } from '.'
import views from '.'
import { TIMEZONE, TRAININGS_DAYS_LIST_COUNT } from '~/constants'
import type { Ctx } from '~/bot/context'
import { Button } from '~/utils/buttons'
import { getDateDayInTimezone, getDayBoundaries } from '~/utils/dates'

const VIEW_ID = 'trainings/day-trainings'

const backButton = new Button({
id: `${VIEW_ID}:back`,
payloadEncoder: () => '',
payloadDecoder: () => null,
})

const dayButton = new Button<Date>({
id: `${VIEW_ID}:day`,
payloadEncoder: payload => payload.toISOString(),
payloadDecoder: data => new Date(data),
})

export default {
render: async (ctx) => {
const keyboard = new InlineKeyboard()
const now = new Date()

for (let i = 0; i < TRAININGS_DAYS_LIST_COUNT; i++) {
const actualDate = new Date(now.getTime())
actualDate.setDate(now.getDate() + i)

const day = getDateDayInTimezone(actualDate, TIMEZONE)
const [timezoneDate, _] = getDayBoundaries({
...day,
timezone: TIMEZONE,
})

keyboard
.text(
ctx.t['Views.TrainingsDaysList.Buttons.Day'](timezoneDate),
dayButton.createCallbackData(actualDate),
)
.row()
}

keyboard.row()
keyboard.text(ctx.t['Buttons.Back'], backButton.createCallbackData(null))

return {
type: 'text',
text: ctx.t['Views.TrainingsDaysList.Message'],
keyboard: keyboard,
}
},
middleware: () => {
const composer = new Composer<Ctx>()

composer
.filter(dayButton.filter)
.use(async (ctx) => {
await ctx.editMessage({
chatId: ctx.chat!.id,
messageId: ctx.callbackQuery.message!.message_id,
content: await views.trainingsDayTrainings.render(ctx, { date: ctx.payload }),
})
ctx.answerCallbackQuery()
})

composer
.filter(backButton.filter)
.use(async (ctx) => {
ctx.answerCallbackQuery()
await ctx.editMessage({
chatId: ctx.chat!.id,
messageId: ctx.callbackQuery.message!.message_id,
content: await views.main.render(ctx, {}),
})
})

return composer.middleware()
},
} satisfies View<Ctx> as View<Ctx>
106 changes: 106 additions & 0 deletions backend/src/bot/handlers/views/trainings_training.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Composer, InlineKeyboard } from 'grammy'
import type { View } from '.'
import views from '.'
import type { Ctx } from '~/bot/context'
import { Button } from '~/utils/buttons'
import type { TrainingDetailed } from '~/services/sport/types'

const VIEW_ID = 'trainings/training'

const backButton = new Button<{ date: Date }>({
id: `${VIEW_ID}:back`,
payloadEncoder: payload => payload.date.toISOString(),
payloadDecoder: data => ({ date: new Date(data) }),
})

const checkInButton = new Button<{ trainingId: number }>({
id: `${VIEW_ID}:check-in`,
payloadEncoder: ({ trainingId }) => trainingId.toString(),
payloadDecoder: data => ({ trainingId: Number.parseInt(data) }),
})
const cancelCheckInButton = new Button<{ trainingId: number }>({
id: `${VIEW_ID}:cancel-check-in`,
payloadEncoder: ({ trainingId }) => trainingId.toString(),
payloadDecoder: data => ({ trainingId: Number.parseInt(data) }),
})

export type Props = {
training: TrainingDetailed
}

export default {
render: async (ctx, { training }) => {
const keyboard = new InlineKeyboard()
if (training.checkedIn) {
keyboard.text(
ctx.t['Views.Training.Buttons.CancelCheckIn'],
cancelCheckInButton.createCallbackData({ trainingId: training.id }),
)
} else if (training.checkInAvailable) {
keyboard.text(
ctx.t['Views.Training.Buttons.CheckIn'],
checkInButton.createCallbackData({ trainingId: training.id }),
)
}
keyboard.row()
keyboard.text(ctx.t['Buttons.Back'], backButton.createCallbackData({ date: training.startsAt }))

return {
type: 'text',
text: ctx.t['Views.Training.Message'](training),
keyboard: keyboard,
}
},
middleware: () => {
const composer = new Composer<Ctx>()

composer
.filter(checkInButton.filter)
.use(async (ctx) => {
await ctx.domain.checkInUserForTraining({
telegramId: ctx.from!.id,
trainingId: ctx.payload.trainingId,
})
ctx.answerCallbackQuery({
text: 'Checked-in',
show_alert: true,
})
await ctx.editMessage({
chatId: ctx.chat!.id,
messageId: ctx.callbackQuery.message!.message_id,
content: await views.main.render(ctx, {}),
})
})

composer
.filter(cancelCheckInButton.filter)
.use(async (ctx) => {
await ctx.domain.cancelCheckInUserForTraining({
telegramId: ctx.from!.id,
trainingId: ctx.payload.trainingId,
})
ctx.answerCallbackQuery({
text: 'Canceled check-in',
show_alert: true,
})
await ctx.editMessage({
chatId: ctx.chat!.id,
messageId: ctx.callbackQuery.message!.message_id,
content: await views.main.render(ctx, {}),
})
})

composer
.filter(backButton.filter)
.use(async (ctx) => {
await ctx.editMessage({
chatId: ctx.chat!.id,
messageId: ctx.callbackQuery.message!.message_id,
content: await views.trainingsDayTrainings.render(ctx, ctx.payload),
})
ctx.answerCallbackQuery()
})

return composer.middleware()
},
} satisfies View<Ctx, Props> as View<Ctx, Props>
2 changes: 2 additions & 0 deletions backend/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const TIMEZONE = 'Europe/Moscow'
export const TRAININGS_DAYS_LIST_COUNT = 7
Loading

0 comments on commit f396df9

Please sign in to comment.