Skip to content

Commit

Permalink
feat: event-based trainings overview (#599)
Browse files Browse the repository at this point in the history
  • Loading branch information
guidojw authored Sep 21, 2023
1 parent 38963cc commit c946b75
Show file tree
Hide file tree
Showing 16 changed files with 203 additions and 92 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"emoji-regex": "^10.2.1",
"inversify": "^6.0.1",
"lodash": "^4.17.21",
"node-cron": "^3.0.2",
"node-schedule": "^2.1.1",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"pluralize": "^8.0.0",
Expand All @@ -39,7 +39,7 @@
"@types/common-tags": "^1.8.1",
"@types/lodash": "^4.14.197",
"@types/node": "^18.17.14",
"@types/node-cron": "^3.0.8",
"@types/node-schedule": "^2.1.0",
"@types/pluralize": "^0.0.30",
"@types/ws": "^8.5.5",
"@typescript-eslint/eslint-plugin": "^6.5.0",
Expand Down
2 changes: 1 addition & 1 deletion src/client/setting-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,6 @@ export default class SettingProvider {
// Remove more from the relations and put it here if above error returns..

const context = this.guildContexts.add(data, { id: data.id, extras: [guild] })
context.init()
await context.init()
}
}
3 changes: 3 additions & 0 deletions src/client/websocket/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export { default as RankChangePacketHandler } from './rank-change'
export { default as TrainingCancelPacketHandler } from './training-cancel'
export { default as TrainingCreatePacketHandler } from './training-create'
export { default as TrainingUpdatePacketHandler } from './training-update'
40 changes: 40 additions & 0 deletions src/client/websocket/handlers/training-cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { inject, injectable, named } from 'inversify'
import { AnnounceTrainingsJob } from '../../../jobs'
import type { BaseHandler } from '../..'
import { GuildContextManager } from '../../../managers'
import type { Training } from '../../../services'
import { constants } from '../../../utils'
import cron from 'node-schedule'

const { TYPES } = constants

interface TraniningCancelPacket {
groupId: number
training: Training
}

@injectable()
export default class TrainingCancelPacketHandler implements BaseHandler {
@inject(TYPES.Job)
@named('announceTrainings')
private readonly announceTrainingsJob!: AnnounceTrainingsJob

@inject(TYPES.Manager)
@named('GuildContextManager')
private readonly guildContexts!: GuildContextManager

public async handle ({ data }: { data: TraniningCancelPacket }): Promise<void> {
const { groupId, training } = data
for (const context of this.guildContexts.cache.values()) {
if (context.robloxGroupId === groupId) {
const jobName = `training_${training.id}`
const job = cron.scheduledJobs[jobName]
if (typeof job !== 'undefined') {
job.cancel()
}

await this.announceTrainingsJob.run(context)
}
}
}
}
33 changes: 33 additions & 0 deletions src/client/websocket/handlers/training-create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { inject, injectable, named } from 'inversify'
import { AnnounceTrainingsJob } from '../../../jobs'
import type { BaseHandler } from '../..'
import { GuildContextManager } from '../../../managers'
import type { Training } from '../../../services'
import { constants } from '../../../utils'

const { TYPES } = constants

interface TraniningCreatePacket {
groupId: number
training: Training
}

@injectable()
export default class TrainingCreatePacketHandler implements BaseHandler {
@inject(TYPES.Job)
@named('announceTrainings')
private readonly announceTrainingsJob!: AnnounceTrainingsJob

@inject(TYPES.Manager)
@named('GuildContextManager')
private readonly guildContexts!: GuildContextManager

public async handle ({ data }: { data: TraniningCreatePacket }): Promise<void> {
const { groupId } = data
for (const context of this.guildContexts.cache.values()) {
if (context.robloxGroupId === groupId) {
await this.announceTrainingsJob.run(context)
}
}
}
}
40 changes: 40 additions & 0 deletions src/client/websocket/handlers/training-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { inject, injectable, named } from 'inversify'
import { AnnounceTrainingsJob } from '../../../jobs'
import type { BaseHandler } from '../..'
import { GuildContextManager } from '../../../managers'
import type { Training } from '../../../services'
import { constants } from '../../../utils'
import cron from 'node-schedule'

const { TYPES } = constants

interface TraniningUpdatePacket {
groupId: number
training: Training
}

@injectable()
export default class TrainingUpdatePacketHandler implements BaseHandler {
@inject(TYPES.Job)
@named('announceTrainings')
private readonly announceTrainingsJob!: AnnounceTrainingsJob

@inject(TYPES.Manager)
@named('GuildContextManager')
private readonly guildContexts!: GuildContextManager

public async handle ({ data }: { data: TraniningUpdatePacket }): Promise<void> {
const { groupId, training } = data
for (const context of this.guildContexts.cache.values()) {
if (context.robloxGroupId === groupId) {
const jobName = `training_${training.id}`
const job = cron.scheduledJobs[jobName]
if (typeof job !== 'undefined') {
job.cancel()
}

await this.announceTrainingsJob.run(context)
}
}
}
}
6 changes: 6 additions & 0 deletions src/configs/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,12 @@ bind<interfaces.AutoNamedFactory<BaseJob>>(TYPES.JobFactory)
// Packet Handlers
bind<BaseHandler>(TYPES.Handler).to(packetHandlers.RankChangePacketHandler)
.whenTargetTagged('packetHandler', 'rankChange')
bind<BaseHandler>(TYPES.Handler).to(packetHandlers.TrainingCancelPacketHandler)
.whenTargetTagged('packetHandler', 'trainingCancel')
bind<BaseHandler>(TYPES.Handler).to(packetHandlers.TrainingCreatePacketHandler)
.whenTargetTagged('packetHandler', 'trainingCreate')
bind<BaseHandler>(TYPES.Handler).to(packetHandlers.TrainingUpdatePacketHandler)
.whenTargetTagged('packetHandler', 'trainingUpdate')

bind<interfaces.SimpleFactory<BaseHandler, [string]>>(TYPES.PacketHandlerFactory)
.toFactory<BaseHandler, [string]>(
Expand Down
4 changes: 0 additions & 4 deletions src/configs/cron.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
export interface CronConfig { name: string, expression: string }

const cronConfig: Record<string, CronConfig> = {
announceTrainingsJob: {
name: 'announceTrainings',
expression: '*/5 * * * *' // https://crontab.guru/#*/5_*_*_*_*
},
healthCheckJob: {
name: 'healthCheck',
expression: '*/5 * * * *' // https://crontab.guru/#*/5_*_*_*_*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { applicationAdapter } from '../../../../adapters'
import applicationConfig from '../../../../configs/application'

const { TYPES } = constants
const { getDate, getDateInfo, getTime, getTimeInfo, getTimeZoneAbbreviation } = timeUtil
const { getDate, getDateInfo, getTime, getTimeInfo } = timeUtil
const { noChannels, noTags, noUrls, validDate, validTime, validators } = argumentUtil

const parseKey = (val: string): string => val.toLowerCase()
Expand Down Expand Up @@ -237,8 +237,8 @@ export default class TrainingsCommand extends SubCommandCommand<TrainingsCommand
.setTitle(`Training ${training.id}`)
.addFields([
{ name: 'Type', value: training.type?.abbreviation ?? 'Deleted', inline: true },
{ name: 'Date', value: getDate(date), inline: true },
{ name: 'Time', value: `${getTime(date)} ${getTimeZoneAbbreviation(date)}`, inline: true },
{ name: 'Date', value: `<t:${date.getTime()}:d>`, inline: true },
{ name: 'Time', value: `<t:${date.getTime()}:t>`, inline: true },
{ name: 'Host', value: username, inline: true }
])
.setColor(context.primaryColor ?? applicationConfig.defaultColor)
Expand Down
51 changes: 16 additions & 35 deletions src/jobs/announce-trainings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import cron, { type JobCallback } from 'node-schedule'
import { groupService, userService } from '../services'
import type BaseJob from './base'
import { EmbedBuilder } from 'discord.js'
Expand All @@ -8,16 +9,12 @@ import { applicationAdapter } from '../adapters'
import applicationConfig from '../configs/application'
import { injectable } from 'inversify'
import lodash from 'lodash'
import pluralize from 'pluralize'
import { timeUtil } from '../utils'

const { getDate, getTime, getTimeZoneAbbreviation } = timeUtil

@injectable()
export default class AnnounceTrainingsJob implements BaseJob {
public async run (context: GuildContext): Promise<void> {
if (context.robloxGroupId === null) {
return
throw new Error(`GuildContext with id '${context.id}' has no robloxGroupId`)
}
const trainingsInfoPanel = context.panels.resolve('trainingsInfoPanel')
const trainingsPanel = context.panels.resolve('trainingsPanel')
Expand All @@ -29,6 +26,18 @@ export default class AnnounceTrainingsJob implements BaseJob {
'GET',
`v1/groups/${context.robloxGroupId}/trainings?sort=date`
)).data
for (const training of trainings) {
const jobName = `training_${training.id}`
const job = cron.scheduledJobs[jobName]
if (typeof job === 'undefined') {
cron.scheduleJob(
jobName,
new Date(new Date(training.date).getTime() + 15 * 60_000),
this.run.bind(this, context) as JobCallback
)
}
}

const authorIds = [...new Set(trainings.map(training => training.authorId))]
const authors = await userService.getUsers(authorIds)

Expand All @@ -37,10 +46,6 @@ export default class AnnounceTrainingsJob implements BaseJob {
const embed = trainingsInfoPanel.embed.setColor(context.primaryColor ?? applicationConfig.defaultColor)
const now = new Date()

if (typeof embed.data.description !== 'undefined') {
embed.setDescription(embed.data.description.replace(/{timezone}/g, getTimeZoneAbbreviation(now)))
}

const nextTraining = trainings.find(training => new Date(training.date) > now)
embed.addFields([
{
Expand Down Expand Up @@ -102,29 +107,10 @@ async function getTrainingsEmbed (groupId: number, trainings: Training[], author
}

function getTrainingMessage (training: Training, authors: GetUsers): string {
const now = new Date()
const today = now.getDate()
const date = new Date(training.date)
const timeString = getTime(date)
const trainingDay = date.getDate()
const dateString = trainingDay === today ? 'Today' : trainingDay === today + 1 ? 'Tomorrow' : getDate(date)
const author = authors.find(author => author.id === training.authorId)
const hourDifference = date.getHours() - now.getHours()

let result = `:calendar_spiral: **${dateString}** at **${timeString}** hosted by ${author?.name ?? training.authorId}`

if (trainingDay === today && hourDifference <= 5) {
if (hourDifference <= 1) {
const minuteDifference = hourDifference * 60 + date.getMinutes() - now.getMinutes()
if (minuteDifference >= 0) {
result += `\n> :alarm_clock: Starts in: **${pluralize('minute', minuteDifference, true)}**`
} else {
result += `\n> :alarm_clock: Started **${pluralize('minute', -1 * minuteDifference, true)}** ago`
}
} else {
result += `\n> :alarm_clock: Starts in: **${pluralize('hour', hourDifference, true)}**`
}
}
let result = `:calendar_spiral: <t:${date.getTime()}:d> at <t:${date.getTime()}:t> hosted by ${author?.name ?? training.authorId}`

if (training.notes !== null) {
result += `\n> :notepad_spiral: ${training.notes}`
Expand All @@ -133,15 +119,10 @@ function getTrainingMessage (training: Training, authors: GetUsers): string {
}

function getNextTrainingMessage (training: Training, authors: GetUsers): string {
const now = new Date()
const today = now.getDate()
const date = new Date(training.date)
const timeString = getTime(date)
const trainingDay = date.getDate()
const dateString = trainingDay === today ? 'today' : trainingDay === today + 1 ? 'tomorrow' : getDate(date)
const author = authors.find(author => author.id === training.authorId)

let result = `${training.type?.abbreviation ?? '(Deleted)'} **${dateString}** at **${timeString}** hosted by ${author?.name ?? training.authorId}`
let result = `${training.type?.abbreviation ?? '(Deleted)'} training on <t:${date.getTime()}:d> at <t:${date.getTime()}:t> hosted by ${author?.name ?? training.authorId}`

if (training.notes !== null) {
result += `\n${training.notes}`
Expand Down
4 changes: 2 additions & 2 deletions src/loaders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { BaseJob } from '../jobs'
import { RewriteFrames } from '@sentry/integrations'
import { constants } from '../utils'
import container from '../configs/container'
import cron from 'node-cron'
import cron from 'node-schedule'
import cronConfig from '../configs/cron'
import dataSource from '../configs/data-source'

Expand All @@ -29,7 +29,7 @@ export async function init (): Promise<AroraClient> {
const jobFactory = container.get<(jobName: string) => BaseJob>(TYPES.JobFactory)
const healthCheckJobConfig = cronConfig.healthCheckJob
const healthCheckJob = jobFactory(healthCheckJobConfig.name)
cron.schedule(
cron.scheduleJob(
healthCheckJobConfig.expression,
() => {
Promise.resolve(healthCheckJob.run('main')).catch(console.error)
Expand Down
6 changes: 2 additions & 4 deletions src/services/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type GetGroupStatus = GetGroup['shout']
export type GetGroupRole = GetGroupRoles['roles'][0]
export interface ChangeMemberRole { oldRole: GetGroupRole, newRole: GetGroupRole }

const { getDate, getTime, getTimeZoneAbbreviation } = timeUtil
const { getDate } = timeUtil
const { getAbbreviation } = util

/// Move below API types to own package?
Expand Down Expand Up @@ -147,10 +147,8 @@ export async function getTrainingEmbeds (trainings: Training[]): Promise<EmbedBu
export function getTrainingRow (training: Training, { users }: { users: GetUsers }): string {
const username = users.find(user => user.id === training.authorId)?.name ?? training.authorId
const date = new Date(training.date)
const readableDate = getDate(date)
const readableTime = getTime(date)

return `${training.id}. **${training.type?.abbreviation ?? '??'}** training on **${readableDate}** at **${readableTime} ${getTimeZoneAbbreviation(date)}**, hosted by **${username}**.`
return `${training.id}. **${training.type?.abbreviation ?? '??'}** training on <t:${date.getTime()}:d> at <t:${date.getTime()}:t>, hosted by **${username}**.`
}

export function groupTrainingsByType (trainings: Training[]): Record<string, Training[]> {
Expand Down
1 change: 1 addition & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './group'
export * as discordService from './discord'
export * as groupService from './group'
export * as userService from './user'
Expand Down
13 changes: 5 additions & 8 deletions src/structures/guild-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import BaseStructure from './base'
import type { Guild as GuildEntity } from '../entities'
import type { VerificationProvider } from '../utils/constants'
import applicationConfig from '../configs/application'
import cron from 'node-cron'
import cron from 'node-schedule'
import cronConfig from '../configs/cron'

const { TYPES } = constants
Expand Down Expand Up @@ -155,18 +155,15 @@ export default class GuildContext extends BaseStructure<GuildEntity> {
}
}

public init (): void {
public async init (): Promise<void> {
if (applicationConfig.apiEnabled === true) {
const announceTrainingsJobConfig = cronConfig.announceTrainingsJob
const announceTrainingsJob = this.jobFactory(announceTrainingsJobConfig.name)
cron.schedule(announceTrainingsJobConfig.expression, () => {
Promise.resolve(announceTrainingsJob.run(this)).catch(console.error)
})
const announceTrainingsJob = this.jobFactory('announceTrainings')
await announceTrainingsJob.run(this)
}

const premiumMembersReportJobConfig = cronConfig.premiumMembersReportJob
const premiumMembersReportJob = this.jobFactory(premiumMembersReportJobConfig.name)
cron.schedule(premiumMembersReportJobConfig.expression, () => {
cron.scheduleJob(premiumMembersReportJobConfig.expression, () => {
Promise.resolve(premiumMembersReportJob.run(this)).catch(console.error)
})
}
Expand Down
9 changes: 0 additions & 9 deletions src/utils/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,3 @@ export function getTimeInfo (timeString: string): TimeInfo {
const minutes = parseInt(timeString.substring(timeString.indexOf(':') + 1, timeString.length))
return { hours, minutes }
}

export function getTimeZoneAbbreviation (date: Date): string {
return date.toLocaleTimeString('en-us', { hour12: false, hour: '2-digit', minute: '2-digit', timeZoneName: 'long' })
.replace(/^(2[0-4]|[0-1][1-9]):[0-5]\d\s/, '')
.split(' ')
.filter(word => word !== 'Standard')
.map(word => word.charAt(0))
.join('')
}
Loading

0 comments on commit c946b75

Please sign in to comment.