diff --git a/backend/src/bot/handlers/root.tsx b/backend/src/bot/handlers/root.tsx index 8056ea1..0dc5772 100644 --- a/backend/src/bot/handlers/root.tsx +++ b/backend/src/bot/handlers/root.tsx @@ -30,9 +30,8 @@ export default handler((composer) => { }) composer.use(views.main) - composer.use(views.trainingsDaysList) - composer.use(views.trainingsDayTrainings) - composer.use(views.trainingsTraining) + composer.use(views.training) + composer.use(views.trainings) composer.use(views.semestersSummary) composer.use(commands.start) diff --git a/backend/src/bot/handlers/views/index.ts b/backend/src/bot/handlers/views/index.ts index d1d4ea1..d3e9b94 100644 --- a/backend/src/bot/handlers/views/index.ts +++ b/backend/src/bot/handlers/views/index.ts @@ -1,21 +1,11 @@ -import type { Context, MiddlewareFn } from 'grammy' -import type { TgxElement } from '@telegum/tgx' import main from './main' -import trainingsDaysList from './trainings_days-list' -import trainingsDayTrainings from './trainings_day-trainings' -import trainingsTraining from './trainings_training' +import training from './training' +import trainings from './trainings' import semestersSummary from './semesters_summary' -// eslint-disable-next-line ts/ban-types -export type View = { - render: (ctx: C, props: P) => Promise - middleware: () => MiddlewareFn -} - export default { main, - trainingsDaysList, - trainingsDayTrainings, - trainingsTraining, + training, + trainings, semestersSummary, } diff --git a/backend/src/bot/handlers/views/main.tsx b/backend/src/bot/handlers/views/main.tsx index c5a81b2..326c076 100644 --- a/backend/src/bot/handlers/views/main.tsx +++ b/backend/src/bot/handlers/views/main.tsx @@ -1,33 +1,33 @@ -import { Composer } from 'grammy' import { makeButton } from '@telegum/grammy-buttons' -import type { View } from '.' import views from '.' -import type { Ctx } from '~/bot/context' +import { makeView } from '~/bot/utils/view' const VIEW_ID = 'main' const RefreshButton = makeButton({ id: `${VIEW_ID}:refresh` }) -const TrainingsButton = makeButton({ id: `${VIEW_ID}:trainings` }) +const MyTrainingsButton = makeButton({ id: `${VIEW_ID}:my-trainings` }) +const AllTrainingsButton = makeButton({ id: `${VIEW_ID}:all-trainings` }) const SemestersButton = makeButton({ id: `${VIEW_ID}:semesters` }) -export default { +export default makeView({ render: async ctx => ( <> {ctx.t['Views.Main.Message'](await ctx.domain.getOngoingSemesterHoursForUser(ctx.from!.id))} {ctx.t['Views.Main.Buttons.Refresh']}
- {ctx.t['Views.Main.Buttons.Trainings']} + {ctx.t['Views.Main.Buttons.TrainingsMy']} + {ctx.t['Views.Main.Buttons.TrainingsAll']} +
{ctx.t['Views.Main.Buttons.Semesters']}
+
), - middleware: () => { - const composer = new Composer() - + setup: (composer) => { composer .filter(RefreshButton.filter) .use(async (ctx) => { @@ -35,17 +35,27 @@ export default { .edit(ctx.from.id, ctx.msg!.message_id) .with({ ignoreNotModifiedError: true }) .to(await views.main.render(ctx, {})) - ctx.answerCallbackQuery(ctx.t['Views.Main.Alerts.Refreshed']) + await ctx.answerCallbackQuery(ctx.t['Views.Main.Alerts.Refreshed']) + }) + + composer + .filter(MyTrainingsButton.filter) + .use(async (ctx) => { + await ctx + .edit(ctx.from.id, ctx.msg!.message_id) + .with({ ignoreNotModifiedError: true }) + .to(await views.trainings.render(ctx, { tab: { type: 'my' } })) + await ctx.answerCallbackQuery() }) composer - .filter(TrainingsButton.filter) + .filter(AllTrainingsButton.filter) .use(async (ctx) => { await ctx .edit(ctx.from.id, ctx.msg!.message_id) .with({ ignoreNotModifiedError: true }) - .to(await views.trainingsDaysList.render(ctx, {})) - ctx.answerCallbackQuery() + .to(await views.trainings.render(ctx, { tab: { type: 'all' } })) + await ctx.answerCallbackQuery() }) composer @@ -55,9 +65,9 @@ export default { .edit(ctx.from.id, ctx.msg!.message_id) .with({ ignoreNotModifiedError: true }) .to(await views.semestersSummary.render(ctx, {})) - ctx.answerCallbackQuery() + await ctx.answerCallbackQuery() }) return composer.middleware() }, -} satisfies View as View +}) diff --git a/backend/src/bot/handlers/views/semesters_summary.tsx b/backend/src/bot/handlers/views/semesters_summary.tsx index aaf34bb..b442530 100644 --- a/backend/src/bot/handlers/views/semesters_summary.tsx +++ b/backend/src/bot/handlers/views/semesters_summary.tsx @@ -1,14 +1,12 @@ -import { Composer } from 'grammy' import { makeButton } from '@telegum/grammy-buttons' -import type { View } from '.' import views from '.' -import type { Ctx } from '~/bot/context' +import { makeView } from '~/bot/utils/view' const VIEW_ID = 'semesters/summary' const BackButton = makeButton({ id: `${VIEW_ID}:back` }) -export default { +export default makeView({ render: async (ctx) => { const semesters = await ctx.domain.getSemestersSummary(ctx.from!.id) return ( @@ -20,9 +18,7 @@ export default { ) }, - middleware: () => { - const composer = new Composer() - + setup: (composer) => { composer .filter(BackButton.filter) .use(async (ctx) => { @@ -35,4 +31,4 @@ export default { return composer.middleware() }, -} satisfies View as View +}) diff --git a/backend/src/bot/handlers/views/training.tsx b/backend/src/bot/handlers/views/training.tsx new file mode 100644 index 0000000..3de79c2 --- /dev/null +++ b/backend/src/bot/handlers/views/training.tsx @@ -0,0 +1,218 @@ +import { makeButton } from '@telegum/grammy-buttons' +import type { TrainingsTab } from './trainings' +import { TrainingsTabButton, decodeTrainingsTab, encodeTrainingsTab } from './trainings' +import views from '.' +import { makeView } from '~/bot/utils/view' +import { split } from '~/utils/strings' + +const VIEW_ID = 'training' + +export const ToggleCheckInButton = makeButton<{ + trainingId: number + action: 'check-in' | 'cancel-check-in' + /** + * Whether to render the training details view after the action is performed + * or the trainings list view. + */ + renderDetails: boolean + backTab: TrainingsTab +}>({ + id: `${VIEW_ID}:toggle-check-in`, + encode: ({ trainingId, action, renderDetails, backTab }) => `${trainingId}:${action}:${renderDetails ? 1 : 0}:${encodeTrainingsTab(backTab)}`, + decode: (data) => { + const [trainingId, action, renderDetails, backTab] = split(data, ':', 3) + switch (action) { + case 'check-in': + case 'cancel-check-in': + break + default: + throw new Error(`Invalid action: ${action}.`) + } + return { + trainingId: Number(trainingId), + action: action, + renderDetails: Boolean(Number(renderDetails)), + backTab: decodeTrainingsTab(backTab), + } + }, +}) +const ToggleFavoriteButton = makeButton<{ + trainingId: number + action: 'add' | 'remove' + backTab: TrainingsTab +}>({ + id: `${VIEW_ID}:toggle-favorite`, + encode: ({ trainingId, action, backTab }) => `${trainingId}:${action}:${encodeTrainingsTab(backTab)}`, + decode: (data) => { + const [trainingId, action, backTab] = split(data, ':', 2) + switch (action) { + case 'add': + case 'remove': + break + default: + throw new Error(`Invalid action: ${action}.`) + } + return { + trainingId: Number(trainingId), + action: action, + backTab: decodeTrainingsTab(backTab), + } + }, +}) + +export type Props = { + trainingId: number + backTab: TrainingsTab +} + +export default makeView({ + render: async (ctx, { trainingId, backTab }) => { + const user = ctx.user! + const training = await ctx.domain.getTrainingForUser({ + telegramId: user.telegramId, + trainingId: trainingId, + }) + + return ( + <> + {ctx.t['Views.Training.Message'](training)} + + {training.checkedIn && ( + + {ctx.t['Views.Training.Buttons.CancelCheckIn']} + + )} + {training.checkInAvailable && ( + + {ctx.t['Views.Training.Buttons.CheckIn']} + + )} +
+ + {user.favoriteGroupIds.includes(training.groupId) + ? ctx.t['Views.Training.Buttons.RemoveFromFavorites'] + : ctx.t['Views.Training.Buttons.AddToFavorites']} + +
+ + {ctx.t['Buttons.Back']} + +
+ + ) + }, + setup: (composer, view) => { + composer + .filter(ToggleCheckInButton.filter) + .use(async (ctx) => { + const telegramId = ctx.user!.telegramId + const { trainingId, action, renderDetails, backTab } = ctx.payload + let alertMessage + switch (action) { + case 'check-in': { + const result = await ctx.domain.checkInUserForTraining({ telegramId, trainingId }) + + switch (result.type) { + case 'already-checked-in': + alertMessage = ctx.t['Alert.AlreadyCheckedIn'] + break + case 'check-in-unavailable': + alertMessage = ctx.t['Alert.CheckInUnavailable'] + break + case 'checked-in': + alertMessage = ctx.t['Alert.CheckInSuccessful'](result.training) + break + case 'failed': + alertMessage = ctx.t['Alert.CheckInError'] + break + } + + break + } + case 'cancel-check-in': { + const result = await ctx.domain.cancelCheckInUserForTraining({ telegramId, trainingId }) + + switch (result.type) { + case 'not-checked-in': + alertMessage = ctx.t['Alert.NotCheckedIn'] + break + case 'cancelled': + alertMessage = ctx.t['Alert.CheckInCancelled'](result.training) + break + case 'failed': + alertMessage = ctx.t['Alert.CancelCheckInError'] + break + } + + break + } + } + if (renderDetails) { + await ctx + .edit(ctx.from.id, ctx.msg!.message_id) + .with({ ignoreNotModifiedError: true }) + .to(await view.render(ctx, { trainingId, backTab })) + } else { + await ctx + .edit(ctx.from.id, ctx.msg!.message_id) + .with({ ignoreNotModifiedError: true }) + .to(await views.trainings.render(ctx, { tab: backTab })) + } + await ctx.answerCallbackQuery({ text: alertMessage, show_alert: true }) + }) + + composer + .filter(ToggleFavoriteButton.filter) + .use(async (ctx) => { + const { trainingId, action, backTab } = ctx.payload + const user = ctx.user! + let alertMessage + + const { title, groupId } = await ctx.domain.getTrainingForUser({ + telegramId: user.telegramId, + trainingId: trainingId, + }) + + switch (action) { + case 'add': + if (!user.favoriteGroupIds.includes(groupId)) { + ctx.user = await ctx.domain.updateUser({ + telegramId: user.telegramId, + favoriteGroupIds: [...user.favoriteGroupIds, groupId], + }) + } + alertMessage = ctx.t['Views.Training.Alerts.AddedToFavorites'](title) + break + case 'remove': + if (user.favoriteGroupIds.includes(groupId)) { + ctx.user = await ctx.domain.updateUser({ + telegramId: user.telegramId, + favoriteGroupIds: user.favoriteGroupIds.filter(id => id !== groupId), + }) + } + alertMessage = ctx.t['Views.Training.Alerts.RemovedFromFavorites'](title) + break + } + + await ctx.answerCallbackQuery({ text: alertMessage }) + await ctx + .edit(ctx.from.id, ctx.msg!.message_id) + .with({ ignoreNotModifiedError: true }) + .to(await view.render(ctx, { trainingId, backTab })) + }) + }, +}) diff --git a/backend/src/bot/handlers/views/trainings.tsx b/backend/src/bot/handlers/views/trainings.tsx new file mode 100644 index 0000000..bdc9275 --- /dev/null +++ b/backend/src/bot/handlers/views/trainings.tsx @@ -0,0 +1,206 @@ +import { makeButton } from '@telegum/grammy-buttons' +import { ToggleCheckInButton } from './training' +import views from '.' +import { makeView } from '~/bot/utils/view' +import { TIMEZONE, TRAININGS_DAYS_LIST_COUNT } from '~/constants' +import { Day, clockTime } from '~/utils/dates' +import type { TrainingInfo } from '~/services/sport/types' +import { split } from '~/utils/strings' + +const VIEW_ID = 'trainings' + +const BackButton = makeButton({ id: `${VIEW_ID}:back` }) +export const TrainingsTabButton = makeButton({ + id: `${VIEW_ID}:tab`, + encode: encodeTrainingsTab, + decode: decodeTrainingsTab, +}) +const TrainingDetailsButton = makeButton<{ + trainingId: number + fromTab: TrainingsTab +}>({ + id: `${VIEW_ID}:training-details`, + encode: ({ trainingId, fromTab }) => `${trainingId}:${encodeTrainingsTab(fromTab)}`, + decode: (data) => { + const [trainingId, fromTab] = split(data, ':', 1) + return { + trainingId: Number(trainingId), + fromTab: decodeTrainingsTab(fromTab), + } + }, +}) + +export function encodeTrainingsTab(tab: TrainingsTab): string { + switch (tab.type) { + case 'all': + return tab.day ? `all:${tab.day.toString()}` : 'all' + case 'my': + return 'my' + } +} +export function decodeTrainingsTab(data: string): TrainingsTab { + const [type, day] = data.split(':') + switch (type) { + case 'all': + return day + ? { type: 'all', day: Day.fromString(day) } + : { type: 'all' } + case 'my': + return { type: 'my' } + default: + throw new Error(`Invalid tab type: ${type}.`) + } +} + +export type TrainingsTab = + | { type: 'all', day?: Day } + | { type: 'my' } + +export type Props = { + tab: TrainingsTab +} + +const activeTab = (active: boolean, text: string) => active ? `•${text}•` : text + +export default makeView({ + render: async (ctx, { tab }) => { + const user = ctx.user! + const today = Day.fromDate(new Date(), TIMEZONE) + const selectedDay = (tab.type === 'all' && tab.day) || today + const firstDay = today + const lastDay = today.shift(TRAININGS_DAYS_LIST_COUNT - 1) + + let trainings: TrainingInfo[] + let message + switch (tab.type) { + case 'all': + trainings = await ctx.domain.getTrainingsForUser({ + telegramId: user.telegramId, + from: selectedDay.asDate(TIMEZONE), + to: selectedDay.shift(1).asDate(TIMEZONE), + }) + message = ctx.t['Views.Trainings.Messages.AllClasses'] + break + case 'my': + trainings = await ctx.domain.getTrainingsForUser({ + telegramId: user.telegramId, + from: firstDay.asDate(TIMEZONE), + to: lastDay.shift(1).asDate(TIMEZONE), + }) + trainings = trainings.filter(training => training.checkedIn || user.favoriteGroupIds.includes(training.groupId)) + if (trainings.length === 0) { + message = ctx.t['Views.Trainings.Messages.NoMyClasses'] + } else { + message = ctx.t['Views.Trainings.Messages.MyClasses'] + } + break + } + + const keyboard = ( + <> + {firstDay.until(lastDay).map((day) => { + const dayTrainings = trainings.filter((training) => { + const trainingDay = Day.fromDate(training.startsAt, TIMEZONE) + return trainingDay.compare(day) === 0 + }) + + if (tab.type === 'my' && dayTrainings.length === 0) { + return null + } + + return ( + <> + + {activeTab( + tab.type === 'all' && selectedDay.compare(day) === 0, + ctx.t['Views.Trainings.Buttons.Day'](day), + )} + +
+ {dayTrainings.map((training) => { + const isFavorite = user.favoriteGroupIds.includes(training.groupId) + const timeStart = clockTime(training.startsAt, TIMEZONE) + const timeEnd = clockTime(training.endsAt, TIMEZONE) + const statusEmoji = training.checkedIn + ? '🟢' + : training.checkInAvailable + ? '🔵' + : '🔴' + + return ( + <> + + {`${statusEmoji} ${timeStart}—${timeEnd}`} + + + {`${isFavorite ? '⭐️ ' : ''}${training.title}`} + +
+ + ) + })} +
+ + ) + })} + + ) + + return ( + <> + {message} + + + {activeTab(tab.type === 'my', ctx.t['Views.Main.Buttons.TrainingsMy'])} + + + {activeTab(tab.type === 'all', ctx.t['Views.Main.Buttons.TrainingsAll'])} + +
+ {keyboard} +
+ {ctx.t['Buttons.Back']} +
+ + ) + }, + setup: (composer, view) => { + composer + .filter(TrainingsTabButton.filter) + .use(async (ctx) => { + await ctx + .edit(ctx.from.id, ctx.msg!.message_id) + .with({ ignoreNotModifiedError: true }) + .to(await view.render(ctx, { tab: ctx.payload })) + await ctx.answerCallbackQuery() + }) + + composer + .filter(TrainingDetailsButton.filter) + .use(async (ctx) => { + await ctx + .edit(ctx.from.id, ctx.msg!.message_id) + .with({ ignoreNotModifiedError: true }) + .to(await views.training.render(ctx, { + trainingId: ctx.payload.trainingId, + backTab: ctx.payload.fromTab, + })) + await ctx.answerCallbackQuery() + }) + + composer + .filter(BackButton.filter) + .use(async (ctx) => { + await ctx + .edit(ctx.from.id, ctx.msg!.message_id) + .with({ ignoreNotModifiedError: true }) + .to(await views.main.render(ctx, {})) + await ctx.answerCallbackQuery() + }) + }, +}) diff --git a/backend/src/bot/handlers/views/trainings_day-trainings.tsx b/backend/src/bot/handlers/views/trainings_day-trainings.tsx deleted file mode 100644 index 6a53107..0000000 --- a/backend/src/bot/handlers/views/trainings_day-trainings.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { Composer } from 'grammy' -import { makeButton } from '@telegum/grammy-buttons' -import type { View } from '.' -import views from '.' -import type { Ctx } from '~/bot/context' -import { TIMEZONE } from '~/constants' -import { Day, clockTime } from '~/utils/dates' - -const VIEW_ID = 'trainings/day-trainings' - -const BackButton = makeButton({ id: `${VIEW_ID}:back` }) -const TrainingButton = makeButton<{ - trainingId: number - action: 'check-in' | 'cancel-check-in' | 'details' -}>({ - id: `${VIEW_ID}:training`, - encode: ({ trainingId, action }) => `${trainingId}:${action}`, - decode: (data) => { - const [trainingId, action] = data.split(':') - switch (action) { - case 'check-in': - case 'cancel-check-in': - case 'details': - break - default: - throw new Error(`Invalid action: ${action}`) - } - return { - trainingId: Number(trainingId), - action: action, - } - }, -}) - -export type Props = { day: Day } - -const render: View['render'] = async (ctx, { day }) => { - const [from, to] = day.boundaries() - const trainings = await ctx.domain.getTrainingsForUser({ - telegramId: ctx.from!.id, - from: from, - to: to, - }) - trainings.sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime()) - - return ( - <> - {ctx.t['Views.DayTrainings.Message']} - - {trainings.map((training) => { - const timeStart = clockTime(training.startsAt, TIMEZONE) - const timeEnd = clockTime(training.endsAt, TIMEZONE) - const statusEmoji = training.checkedIn - ? '🟢' - : training.checkInAvailable - ? '🔵' - : '🔴' - - return ( - <> - - {`${statusEmoji} ${timeStart}—${timeEnd}`} - - - {training.title} - -
- - ) - })} - {ctx.t['Buttons.Back']} -
- - ) -} - -export default { - render: render, - middleware: () => { - const composer = new Composer() - - composer - .filter(TrainingButton.filter) - .use(async (ctx) => { - const telegramId = ctx.from.id - const trainingId = ctx.payload.trainingId - switch (ctx.payload.action) { - case 'check-in': { - const training = await ctx.domain.getTrainingForUser({ telegramId, trainingId }) - let alertMessage - if (training.checkedIn) { - alertMessage = ctx.t['Alert.AlreadyCheckedIn'] - } else if (!training.checkInAvailable) { - alertMessage = ctx.t['Alert.CheckInUnavailable'] - } else { - try { - await ctx.domain.checkInUserForTraining({ telegramId, trainingId }) - alertMessage = ctx.t['Alert.CheckInSuccessful'](training) - } catch (error) { - ctx.logger.error({ - msg: 'failed to check-in a user', - error: error, - telegramId: telegramId, - trainingId: trainingId, - }) - alertMessage = ctx.t['Alert.CheckInError'] - } - } - ctx.answerCallbackQuery({ text: alertMessage, show_alert: true }) - await ctx - .edit(ctx.from.id, ctx.msg!.message_id) - .with({ ignoreNotModifiedError: true }) - .to(await render(ctx, { day: Day.fromDate(training.startsAt, TIMEZONE) })) - break - } - case 'cancel-check-in': { - const training = await ctx.domain.getTrainingForUser({ telegramId, trainingId }) - let alertMessage - if (!training.checkedIn) { - alertMessage = ctx.t['Alert.NotCheckedIn'] - } else { - try { - await ctx.domain.cancelCheckInUserForTraining({ telegramId, trainingId }) - alertMessage = ctx.t['Alert.CheckInCancelled'](training) - } catch (error) { - ctx.logger.error({ - msg: 'failed to cancel check-in for a user', - error: error, - telegramId: telegramId, - trainingId: trainingId, - }) - alertMessage = ctx.t['Alert.CancelCheckInError'] - } - } - ctx.answerCallbackQuery({ text: alertMessage, show_alert: true }) - await ctx - .edit(ctx.from.id, ctx.msg!.message_id) - .with({ ignoreNotModifiedError: true }) - .to(await render(ctx, { day: Day.fromDate(training.startsAt, TIMEZONE) })) - break - } - case 'details': { - const training = await ctx.domain.getTrainingForUser({ telegramId, trainingId }) - ctx.answerCallbackQuery() - await ctx - .edit(ctx.from.id, ctx.msg!.message_id) - .with({ ignoreNotModifiedError: true }) - .to(await views.trainingsTraining.render(ctx, { training })) - break - } - default: - throw new Error(`Invalid action: ${ctx.payload.action}.`) - } - }) - - composer - .filter(BackButton.filter) - .use(async (ctx) => { - await ctx - .edit(ctx.from.id, ctx.msg!.message_id) - .with({ ignoreNotModifiedError: true }) - .to(await views.trainingsDaysList.render(ctx, {})) - ctx.answerCallbackQuery() - }) - - return composer.middleware() - }, -} satisfies View as View diff --git a/backend/src/bot/handlers/views/trainings_days-list.tsx b/backend/src/bot/handlers/views/trainings_days-list.tsx deleted file mode 100644 index 8d3394b..0000000 --- a/backend/src/bot/handlers/views/trainings_days-list.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Composer } from 'grammy' -import { makeButton } from '@telegum/grammy-buttons' -import type { View } from '.' -import views from '.' -import type { Ctx } from '~/bot/context' -import { TIMEZONE, TRAININGS_DAYS_LIST_COUNT } from '~/constants' -import { Day, getSpanningWeeks } from '~/utils/dates' -import { NoopButton } from '~/bot/plugins/noop-button' - -const VIEW_ID = 'trainings/days-list' - -const BackButton = makeButton({ id: `${VIEW_ID}:back` }) -const DayButton = makeButton<{ day: Day }>({ - id: `${VIEW_ID}:day`, - encode: ({ day }) => day.toString(), - decode: data => ({ day: Day.fromString(data) }), -}) - -export default { - render: async (ctx) => { - // Make keyboard like this: - // - // Mo Tu We Th Fr Sa Su - // -- -- 01 02 03 04 05 - // 06 07 08 -- -- -- -- - - const today = Day.fromDate(new Date(), TIMEZONE) - const lastDay = today.shift(TRAININGS_DAYS_LIST_COUNT - 1) - const weeksDays = getSpanningWeeks(today, lastDay) - - const weekdaysRow = (['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const) - .map(weekday => ( - {ctx.t['Weekday.TwoLetters'](weekday)} - )) - const daysRows = weeksDays.map(weekDays => ( - <> - {weekDays.map(day => ( - day === null - ? - : {day.date.toString()} - ))} -
- - )) - - return ( - <> - {ctx.t['Views.TrainingsDaysList.Message']} - - {weekdaysRow} -
- {daysRows} - {ctx.t['Buttons.Back']} -
- - ) - }, - middleware: () => { - const composer = new Composer() - - composer - .filter(DayButton.filter) - .use(async (ctx) => { - await ctx - .edit(ctx.from.id, ctx.msg!.message_id) - .with({ ignoreNotModifiedError: true }) - .to(await views.trainingsDayTrainings.render(ctx, { day: ctx.payload.day })) - ctx.answerCallbackQuery() - }) - - composer - .filter(BackButton.filter) - .use(async (ctx) => { - await ctx - .edit(ctx.from.id, ctx.msg!.message_id) - .with({ ignoreNotModifiedError: true }) - .to(await views.main.render(ctx, {})) - ctx.answerCallbackQuery() - }) - - return composer.middleware() - }, -} satisfies View as View diff --git a/backend/src/bot/handlers/views/trainings_training.tsx b/backend/src/bot/handlers/views/trainings_training.tsx deleted file mode 100644 index b40240c..0000000 --- a/backend/src/bot/handlers/views/trainings_training.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { Composer } from 'grammy' -import { makeButton } from '@telegum/grammy-buttons' -import type { View } from '.' -import views from '.' -import type { Ctx } from '~/bot/context' -import type { TrainingDetailed } from '~/services/sport/types' -import { Day } from '~/utils/dates' -import { TIMEZONE } from '~/constants' - -const VIEW_ID = 'trainings/training' - -const BackButton = makeButton<{ day: Day }>({ - id: `${VIEW_ID}:back`, - encode: ({ day }) => day.toString(), - decode: data => ({ day: Day.fromString(data) }), -}) -const CheckInButton = makeButton<{ trainingId: number, day: Day }>({ - id: `${VIEW_ID}:check-in`, - encode: ({ trainingId, day }) => `${trainingId}:${day.toString()}`, - decode: (data) => { - const [trainingId, dayStr] = data.split(':') - return { - trainingId: Number(trainingId), - day: Day.fromString(dayStr), - } - }, -}) -const CancelCheckInButton = makeButton<{ trainingId: number, day: Day }>({ - id: `${VIEW_ID}:cancel-check-in`, - encode: ({ trainingId, day }) => `${trainingId}:${day.toString()}`, - decode: (data) => { - const [trainingId, dayStr] = data.split(':') - return { - trainingId: Number(trainingId), - day: Day.fromString(dayStr), - } - }, -}) - -export type Props = { - training: TrainingDetailed -} - -export default { - render: async (ctx, { training }) => { - const trainingDay = Day.fromDate(training.startsAt, TIMEZONE) - return ( - <> - {ctx.t['Views.Training.Message'](training)} - - {training.checkedIn && ( - - {ctx.t['Views.Training.Buttons.CancelCheckIn']} - - )} - {training.checkInAvailable && ( - - {ctx.t['Views.Training.Buttons.CheckIn']} - - )} -
- - {ctx.t['Buttons.Back']} - -
- - ) - }, - middleware: () => { - const composer = new Composer() - - composer - .filter(CheckInButton.filter) - .use(async (ctx) => { - await ctx.domain.checkInUserForTraining({ - telegramId: ctx.from!.id, - trainingId: ctx.payload.trainingId, - }) - ctx.answerCallbackQuery({ - text: ctx.t['Alert.CheckInSuccessfulText'], - show_alert: true, - }) - - await ctx - .edit(ctx.from.id, ctx.msg!.message_id) - .with({ ignoreNotModifiedError: true }) - .to(await views.trainingsDayTrainings.render(ctx, { day: ctx.payload.day })) - }) - - composer - .filter(CancelCheckInButton.filter) - .use(async (ctx) => { - await ctx.domain.cancelCheckInUserForTraining({ - telegramId: ctx.from!.id, - trainingId: ctx.payload.trainingId, - }) - ctx.answerCallbackQuery({ - text: ctx.t['Alert.CheckInCancelledText'], - show_alert: true, - }) - - await ctx - .edit(ctx.from.id, ctx.msg!.message_id) - .with({ ignoreNotModifiedError: true }) - .to(await views.trainingsDayTrainings.render(ctx, { day: ctx.payload.day })) - }) - - composer - .filter(BackButton.filter) - .use(async (ctx) => { - await ctx - .edit(ctx.from.id, ctx.msg!.message_id) - .with({ ignoreNotModifiedError: true }) - .to(await views.trainingsDayTrainings.render(ctx, ctx.payload)) - ctx.answerCallbackQuery() - }) - - return composer.middleware() - }, -} satisfies View as View diff --git a/backend/src/bot/utils/view.ts b/backend/src/bot/utils/view.ts new file mode 100644 index 0000000..21b1f5f --- /dev/null +++ b/backend/src/bot/utils/view.ts @@ -0,0 +1,39 @@ +import { Composer } from 'grammy' +import type { Context, MiddlewareFn } from 'grammy' +import type { TgxElement } from '@telegum/tgx' +import type { Ctx } from '~/bot/context' + +// eslint-disable-next-line ts/ban-types +export type RenderFn = ( + ctx: C, + props: P, +) => Promise + +// eslint-disable-next-line ts/ban-types +export type View = { + render: RenderFn + middleware: () => MiddlewareFn +} + +// eslint-disable-next-line ts/ban-types +export function makeView

({ + render, + setup = () => void 0, +}: { + render: RenderFn + setup?: ( + composer: Composer, + view: View + ) => void +}): View { + const composer = new Composer() + + const view = { + render: render, + middleware: () => composer.middleware(), + } + + setup(composer, view) + + return view +} diff --git a/backend/src/domain/index.ts b/backend/src/domain/index.ts index d68c366..4297f7a 100644 --- a/backend/src/domain/index.ts +++ b/backend/src/domain/index.ts @@ -6,6 +6,7 @@ import type { Logger } from '~/utils/logging' import type { Database } from '~/services/database' import type { SportClient } from '~/services/sport' import type { InnohassleClient } from '~/services/innohassle' +import type { TrainingInfo } from '~/services/sport/types' export class Domain { private logger: Logger @@ -100,14 +101,34 @@ export class Domain { return this.requestSport(telegramId, 'getTraining', trainingId) } - public checkInUserForTraining({ + public async checkInUserForTraining({ telegramId, trainingId, }: { telegramId: number trainingId: number }) { - return this.requestSport(telegramId, 'checkInForTraining', trainingId) + const training = await this.requestSport(telegramId, 'getTraining', trainingId) + let result + if (training.checkedIn) { + result = 'already-checked-in' as const + } else if (!training.checkInAvailable) { + result = 'check-in-unavailable' as const + } else { + try { + await this.requestSport(telegramId, 'checkInForTraining', trainingId) + result = 'checked-in' as const + } catch (error) { + this.logger.error({ + msg: 'failed to check-in a user', + error: error, + telegramId: telegramId, + trainingId: trainingId, + }) + result = 'failed' as const + } + } + return { type: result, training: training } } public async cancelCheckInUserForTraining({ @@ -117,7 +138,25 @@ export class Domain { telegramId: number trainingId: number }) { - return this.requestSport(telegramId, 'cancelCheckInForTraining', trainingId) + const training = await this.requestSport(telegramId, 'getTraining', trainingId) + let result + if (!training.checkedIn) { + result = 'not-checked-in' as const + } else { + try { + await this.requestSport(telegramId, 'cancelCheckInForTraining', trainingId) + result = 'cancelled' as const + } catch (error) { + this.logger.error({ + msg: 'failed to cancel check-in for a user', + error: error, + telegramId: telegramId, + trainingId: trainingId, + }) + result = 'failed' as const + } + } + return { type: result, training: training } } public async getSemestersSummary(telegramId: number): Promise { diff --git a/backend/src/translations/_en.tsx b/backend/src/translations/_en.tsx index 9456e93..1a9e78c 100644 --- a/backend/src/translations/_en.tsx +++ b/backend/src/translations/_en.tsx @@ -2,7 +2,7 @@ import { pluralize } from './utils' import { TIMEZONE } from '~/constants' import type { SemesterSummary } from '~/domain/types' import type { TrainingDetailed } from '~/services/sport/types' -import type { Weekday } from '~/utils/dates' +import type { Day } from '~/utils/dates' import { clockTime } from '~/utils/dates' import { tgxFromHtml } from '~/utils/tgx-from-html' @@ -145,15 +145,22 @@ export default { } }, 'Views.Main.Buttons.Refresh': '🔄 Refresh', - 'Views.Main.Buttons.Trainings': '⛹️ Classes', + 'Views.Main.Buttons.TrainingsMy': '⭐️ My classes', + 'Views.Main.Buttons.TrainingsAll': '⛹️ All classes', 'Views.Main.Buttons.Semesters': '📊 Semesters', + 'Views.Main.Buttons.Calendar': '📆 Personal calendar', 'Views.Main.Buttons.Website': '🌐 Website', - 'Views.Main.Buttons.Calendar': '📆 Calendar', 'Views.Main.Alerts.Refreshed': 'Refreshed!', - 'Views.TrainingsDaysList.Message': 'Choose the date:', - - 'Views.DayTrainings.Message': 'Choose the class:', + 'Views.Trainings.Buttons.Day': (day: Day) => { + const dayOfWeek = day.asDate(TIMEZONE).toLocaleDateString('en-US', { weekday: 'long', timeZone: TIMEZONE }) + const month = day.asDate(TIMEZONE).toLocaleDateString('en-US', { month: 'long', timeZone: TIMEZONE }) + const dayOfMonth = day.asDate(TIMEZONE).toLocaleDateString('en-US', { day: 'numeric', timeZone: TIMEZONE }) + return `${dayOfWeek}, ${month} ${dayOfMonth}` + }, + 'Views.Trainings.Messages.AllClasses': 'Here is a list of all classes for the upcoming week:', + 'Views.Trainings.Messages.NoMyClasses': 'You have no favorite or checked-in classes for the upcoming week.', + 'Views.Trainings.Messages.MyClasses': 'Your favorite and checked-in classes for the upcoming week:', 'Views.Training.Message': ({ title, @@ -196,6 +203,10 @@ export default { ), 'Views.Training.Buttons.CheckIn': 'Check-in', 'Views.Training.Buttons.CancelCheckIn': 'Cancel check-in', + 'Views.Training.Buttons.AddToFavorites': '⭐️ Add to favorites', + 'Views.Training.Buttons.RemoveFromFavorites': '⭐️ Remove from favorites', + 'Views.Training.Alerts.AddedToFavorites': (title: string) => `"${title}" is now a favorite.`, + 'Views.Training.Alerts.RemovedFromFavorites': (title: string) => `"${title}" is no longer a favorite.`, 'Views.SemestersSummary.SummaryMessage': (semesters: SemesterSummary[]) => ( <> @@ -223,16 +234,4 @@ export default { ))} ), - - 'Weekday.TwoLetters': (weekday: Weekday): string => { - switch (weekday) { - case 'mon': return 'Mo' - case 'tue': return 'Tu' - case 'wed': return 'We' - case 'thu': return 'Th' - case 'fri': return 'Fr' - case 'sat': return 'Sa' - case 'sun': return 'Su' - } - }, } diff --git a/backend/src/translations/_ru.tsx b/backend/src/translations/_ru.tsx index 2ae1dca..c688ce3 100644 --- a/backend/src/translations/_ru.tsx +++ b/backend/src/translations/_ru.tsx @@ -2,10 +2,10 @@ import { pluralize } from './utils' import type { Translation } from '.' import { TIMEZONE } from '~/constants' import type { TrainingDetailed } from '~/services/sport/types' -import type { Weekday } from '~/utils/dates' import { clockTime } from '~/utils/dates' import { tgxFromHtml } from '~/utils/tgx-from-html' import type { SemesterSummary } from '~/domain/types' +import { capitalize } from '~/utils/strings' function dateLong(date: Date): string { const weekDayShort = date.toLocaleDateString('ru-RU', { weekday: 'long', timeZone: TIMEZONE }) @@ -154,15 +154,26 @@ export default { } }, 'Views.Main.Buttons.Refresh': '🔄 Обновить', - 'Views.Main.Buttons.Trainings': '⛹️ Занятия', + 'Views.Main.Buttons.TrainingsMy': '⭐️ Мои занятия', + 'Views.Main.Buttons.TrainingsAll': '⛹️ Все занятия', 'Views.Main.Buttons.Semesters': '📊 Семестры', + 'Views.Main.Buttons.Calendar': '📆 Личный календарь', 'Views.Main.Buttons.Website': '🌐 Сайт', - 'Views.Main.Buttons.Calendar': '📆 Календарь', 'Views.Main.Alerts.Refreshed': 'Обновлено!', - 'Views.TrainingsDaysList.Message': 'Выбери дату:', - - 'Views.DayTrainings.Message': 'Выбери занятие:', + 'Views.Trainings.Buttons.Day': (day) => { + return capitalize( + day.asDate(TIMEZONE).toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'long', + weekday: 'long', + timeZone: TIMEZONE, + }), + ) + }, + 'Views.Trainings.Messages.AllClasses': 'Список всех занятий на ближайшую неделю:', + 'Views.Trainings.Messages.NoMyClasses': 'На ближайшую неделю нет избранных занятий и записей.', + 'Views.Trainings.Messages.MyClasses': 'Список избранных занятий и записей на ближайшую неделю:', 'Views.Training.Message': ({ title, @@ -205,6 +216,10 @@ export default { ), 'Views.Training.Buttons.CheckIn': 'Записаться', 'Views.Training.Buttons.CancelCheckIn': 'Отменить запись', + 'Views.Training.Buttons.AddToFavorites': '⭐️ Добавить в избранное', + 'Views.Training.Buttons.RemoveFromFavorites': '⭐️ Убрать из избранного', + 'Views.Training.Alerts.AddedToFavorites': (title: string) => `"${title}" теперь в избранном.`, + 'Views.Training.Alerts.RemovedFromFavorites': (title: string) => `"${title}" больше не в избранном.`, 'Views.SemestersSummary.SummaryMessage': (semesters: SemesterSummary[]) => ( <> @@ -232,16 +247,4 @@ export default { ))} ), - - 'Weekday.TwoLetters': (weekday: Weekday) => { - switch (weekday) { - case 'mon': return 'Пн' - case 'tue': return 'Вт' - case 'wed': return 'Ср' - case 'thu': return 'Чт' - case 'fri': return 'Пт' - case 'sat': return 'Сб' - case 'sun': return 'Вс' - } - }, } satisfies Translation diff --git a/backend/src/utils/dates.test.ts b/backend/src/utils/dates.test.ts index c80bb41..d812c47 100644 --- a/backend/src/utils/dates.test.ts +++ b/backend/src/utils/dates.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import type { Weekday } from './dates' import { Day, getTimezoneOffset } from './dates' describe('getTimezoneOffset', () => { @@ -50,37 +49,6 @@ describe('day', () => { expect(date.toISOString()).toBe('2024-03-17T03:00:00.000Z') }) - it('returns correct weekday', () => { - const testcases: [string, Weekday][] = [ - ['2024-01-01', 'mon'], - ['2024-01-02', 'tue'], - ['2024-01-03', 'wed'], - ['2024-01-04', 'thu'], - ['2024-01-05', 'fri'], - ['2024-01-06', 'sat'], - ['2024-01-07', 'sun'], - - ['2024-04-01', 'mon'], - ['2024-04-02', 'tue'], - ['2024-04-03', 'wed'], - ['2024-04-04', 'thu'], - ['2024-04-05', 'fri'], - ['2024-04-06', 'sat'], - ['2024-04-07', 'sun'], - - ['2024-12-30', 'mon'], - ['2024-12-31', 'tue'], - ['2025-01-01', 'wed'], - ['2025-01-02', 'thu'], - ['2025-01-03', 'fri'], - ['2025-01-04', 'sat'], - ['2025-01-05', 'sun'], - ] - for (const [dateStr, weekday] of testcases) { - expect(Day.fromString(dateStr).weekday).toBe(weekday) - } - }) - it('shifts correctly', () => { const testcases: [string, number, string][] = [ ['2024-01-01', -3, '2023-12-29'], diff --git a/backend/src/utils/dates.ts b/backend/src/utils/dates.ts index b7cc745..e919d20 100644 --- a/backend/src/utils/dates.ts +++ b/backend/src/utils/dates.ts @@ -1,5 +1,3 @@ -export type Weekday = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun' - export class Day { constructor(public year: number, public month: number, public date: number) {} @@ -32,16 +30,15 @@ export class Day { }` } + /** + * Returns a new `Date` object for this day with the time set + * to `00:00:00.000` in the given timezone. + */ public asDate(timezone: string = 'UTC'): Date { const offset = getTimezoneOffset(timezone) return new Date(Date.UTC(this.year, this.month - 1, this.date) + offset) } - public get weekday(): Weekday { - const date = new Date(this.year, this.month - 1, this.date) - return date.toLocaleString('en-US', { weekday: 'short' }).toLowerCase() as Weekday - } - public boundaries(timezone: string = 'UTC'): [Date, Date] { const offset = getTimezoneOffset(timezone) const startDateUtc = new Date(Date.UTC(this.year, this.month - 1, this.date)) @@ -77,6 +74,14 @@ export class Day { date.setDate(date.getDate() + days) return Day.fromDate(date) } + + public until(end: Day): Day[] { + const days = [] + for (let day = this as Day; day.compare(end) <= 0; day = day.shift(1)) { + days.push(day) + } + return days + } } /** @@ -119,40 +124,3 @@ export function clockTime(date: Date, timezone?: string): string { timeZone: timezone, }) } - -/** - * Returns an array of days that span from the first Monday on or before `from` - * to the last Sunday on or after `to`. The array contains `null` for days - * outside the range `[from, to]`. - */ -export function getSpanningWeeks(from: Day, to: Day): (null | Day)[][] { - if (from.compare(to) > 0) { - return [] - } - - let tmp = from - while (tmp.weekday !== 'mon') { - tmp = tmp.shift(-1) - } - const firstMon = tmp - - tmp = to - while (tmp.weekday !== 'sun') { - tmp = tmp.shift(1) - } - const lastSun = tmp - - const weeks: (null | Day)[][] = [] - for (let day = firstMon; day.compare(lastSun) <= 0; day = day.shift(1)) { - if (day.weekday === 'mon') { - weeks.push([]) - } - if (day.compare(from) >= 0 && day.compare(to) <= 0) { - weeks[weeks.length - 1].push(day) - } else { - weeks[weeks.length - 1].push(null) - } - } - - return weeks -} diff --git a/backend/src/utils/strings.ts b/backend/src/utils/strings.ts new file mode 100644 index 0000000..aab2841 --- /dev/null +++ b/backend/src/utils/strings.ts @@ -0,0 +1,19 @@ +/** + * Splits a string like in Python's `str.split` function. + */ +export function split(str: string, sep: string, maxSplit: number = -1): string[] { + const parts = str.split(sep) + if (maxSplit < 0) { + return parts + } + const splitsLeft = parts.slice(0, maxSplit) + const rest = parts.slice(maxSplit) + if (rest.length > 0) { + splitsLeft.push(rest.join(sep)) + } + return splitsLeft +} + +export function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1) +}