77import net .dv8tion .jda .api .interactions .commands .OptionMapping ;
88import net .dv8tion .jda .api .interactions .commands .OptionType ;
99import net .dv8tion .jda .api .interactions .commands .build .OptionData ;
10- import org .jooq .DSLContext ;
1110import org .jooq .Field ;
1211import org .jooq .OrderField ;
12+ import org .jooq .Record ;
1313import org .jooq .Record1 ;
1414
1515import org .togetherjava .tjbot .db .Database ;
1616import org .togetherjava .tjbot .features .CommandVisibility ;
1717import org .togetherjava .tjbot .features .SlashCommandAdapter ;
1818
19+ import javax .annotation .Nullable ;
20+
1921import java .awt .Color ;
2022import java .time .Duration ;
2123import java .time .Instant ;
2224import java .time .temporal .ChronoUnit ;
2325import java .util .Objects ;
26+ import java .util .Optional ;
2427
2528import static org .jooq .impl .DSL .avg ;
2629import static org .jooq .impl .DSL .count ;
@@ -56,11 +59,11 @@ public class HelpThreadStatsCommand extends SlashCommandAdapter {
5659 private static final String MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "min_sec" ;
5760 private static final String MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "max_sec" ;
5861
59- private static final String EMOJI_CHART = "\uD83D \uDCCA " ;
60- private static final String EMOJI_MEMO = "\uD83D \uDCDD " ;
61- private static final String EMOJI_SPEECH_BUBBLE = "\uD83D \uDCAC " ;
62- private static final String EMOJI_LABEL = "\uD83C \uDFF7 \uFE0F " ;
63- private static final String EMOJI_LIGHTNING = "\u26A1 " ;
62+ private static final String EMOJI_CHART = "📊 " ;
63+ private static final String EMOJI_MEMO = "📝 " ;
64+ private static final String EMOJI_SPEECH_BUBBLE = "💬 " ;
65+ private static final String EMOJI_LABEL = "🏷️ " ;
66+ private static final String EMOJI_LIGHTNING = "⚡ " ;
6467
6568 private static final String EMBED_BLANK_LINE = "\u200B " ;
6669 private static final String WHITESPACE = " " ;
@@ -94,32 +97,19 @@ public HelpThreadStatsCommand(Database database) {
9497 Caffeine .newBuilder ().maximumSize (500 ).expireAfterWrite (COOLDOWN_DURATION ).build ();
9598 }
9699
97- @ Override
98- public void onSlashCommand (SlashCommandInteractionEvent event ) {
99- long channelId = event .getChannel ().getIdLong ();
100- Instant now = Instant .now ();
101-
100+ private long getSecondsSinceLastUsage (long channelId , Instant now ) {
101+ long secondsSinceLastUsage = 0 ;
102102 Instant lastUsage = this .cooldownCache .getIfPresent (channelId );
103103 if (lastUsage != null ) {
104104 Duration elapsed = Duration .between (lastUsage , now );
105105 // to avoid displaying -1 when elapsed just crosses cooldown
106- long secondsLeft = Math .max (0 , COOLDOWN_DURATION .minus (elapsed ).toSeconds ());
107-
108- event
109- .reply ("This command is on cooldown! Please wait " + secondsLeft + " more seconds." )
110- .setEphemeral (true )
111- .queue ();
112- return ;
106+ secondsSinceLastUsage = Math .max (0 , COOLDOWN_DURATION .minus (elapsed ).toSeconds ());
113107 }
108+ return secondsSinceLastUsage ;
109+ }
114110
115- cooldownCache .put (channelId , now );
116-
117- long days = event .getOption (DURATION_OPTION , 1L , OptionMapping ::getAsLong );
118- Instant startDate = Instant .now ().minus (days , ChronoUnit .DAYS );
119-
120- event .deferReply ().queue ();
121-
122- database .read (context -> {
111+ private Optional <Record > getHelpThreadUsageStats (Instant statsDurationStartDate ) {
112+ return database .read (context -> {
123113 var statsRecord = context
124114 .select (count ().as (TOTAL_CREATED_FIELD ), count ()
125115 .filterWhere (
@@ -129,108 +119,157 @@ public void onSlashCommand(SlashCommandInteractionEvent event) {
129119 avg (HELP_THREADS .PARTICIPANTS ).as (AVERAGE_PARTICIPANTS_ALIAS ),
130120 avg (HELP_THREADS .MESSAGE_COUNT ).as (AVERAGE_MESSAGE_COUNT_ALIAS ),
131121 avg (durationInSeconds (HELP_THREADS .CLOSED_AT , HELP_THREADS .CREATED_AT ))
122+ .filterWhere (HELP_THREADS .PARTICIPANTS .gt (0 ))
132123 .as (AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS ),
133124 min (durationInSeconds (HELP_THREADS .CLOSED_AT , HELP_THREADS .CREATED_AT ))
125+ .filterWhere (HELP_THREADS .PARTICIPANTS .gt (0 ))
134126 .as (MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS ),
135127 max (durationInSeconds (HELP_THREADS .CLOSED_AT , HELP_THREADS .CREATED_AT ))
128+ .filterWhere (HELP_THREADS .PARTICIPANTS .gt (0 ))
136129 .as (MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS ))
137130 .from (HELP_THREADS )
138- .where (HELP_THREADS .CREATED_AT .ge (startDate ))
131+ .where (HELP_THREADS .CREATED_AT .ge (statsDurationStartDate ))
139132 .fetchOne ();
140133
141134 if (statsRecord == null || statsRecord .get (TOTAL_CREATED_FIELD , Integer .class ) == 0 ) {
142- event .getHook ()
143- .editOriginal ("No stats available for the last " + days + " days." )
144- .queue ();
145- return null ;
135+ return Optional .empty ();
146136 }
137+ return Optional .of (statsRecord );
138+ });
139+ }
140+
141+ private record StatsReportData (long days , int totalCreated , int openThreads , long ghostThreads ,
142+ double responseRate , String highVolumeTag , String highActivityTag ,
143+ String lowActivityTag , String peakHourRange , Record rawStats
147144
148- int totalCreated = statsRecord .get (TOTAL_CREATED_FIELD , Integer .class );
149- int openThreads = statsRecord .get (OPEN_NOW_ALIAS , Integer .class );
150- long ghostThreads = statsRecord .get (GHOST_NOW_ALIAS , Number .class ).longValue ();
145+ ) {
146+ }
147+
148+ private EmbedBuilder buildStatsEmbed (StatsReportData helpThreadStatsResults ,
149+ String guildIconUrl , int daysBack ) {
150+ EmbedBuilder helpThreadStatsEmbed = new EmbedBuilder ()
151+ .setTitle (EMOJI_CHART + " Help Thread Stats (Last " + daysBack + " Days)" )
152+ .setColor (getStatusColor (helpThreadStatsResults .totalCreated (),
153+ helpThreadStatsResults .ghostThreads ()))
154+ .setTimestamp (Instant .now ())
155+ .setDescription (EMBED_BLANK_LINE )
156+ .setFooter ("Together Java Community Stats" , guildIconUrl );
157+
158+ helpThreadStatsEmbed .addField (EMOJI_MEMO + WHITESPACE + "THREAD ACTIVITY" ,
159+ "Created: `%d`%nCurrently Open: `%d`%nResponse Rate: %.1f%%%nPeak Hours: `%s`"
160+ .formatted (helpThreadStatsResults .totalCreated (),
161+ helpThreadStatsResults .openThreads (),
162+ helpThreadStatsResults .responseRate (),
163+ helpThreadStatsResults .peakHourRange ()),
164+ false );
165+
166+ helpThreadStatsEmbed .addField (EMOJI_SPEECH_BUBBLE + WHITESPACE + "ENGAGEMENT" ,
167+ "Avg Messages: `%s`%nAvg Helpers: `%s`%nUnanswered (Ghost): `%d`" .formatted (
168+ formatDouble (Objects .requireNonNull (helpThreadStatsResults .rawStats ()
169+ .get (AVERAGE_MESSAGE_COUNT_ALIAS ))),
170+ formatDouble (Objects .requireNonNull (
171+ helpThreadStatsResults .rawStats ().get (AVERAGE_PARTICIPANTS_ALIAS ))),
172+ helpThreadStatsResults .ghostThreads ),
173+ false );
174+
175+ helpThreadStatsEmbed .addField (EMOJI_LABEL + WHITESPACE + "TAG ACTIVITY" ,
176+ "Most Used: `%s`%nMost Active: `%s`%nNeeds Love: `%s`" .formatted (
177+ helpThreadStatsResults .highVolumeTag (),
178+ helpThreadStatsResults .highActivityTag (),
179+ helpThreadStatsResults .lowActivityTag ()),
180+ false );
181+
182+ helpThreadStatsEmbed .addField (EMOJI_LIGHTNING + WHITESPACE + "RESOLUTION SPEED" ,
183+ "Average: `%s`%nFastest: `%s`%nSlowest: `%s`" .formatted (
184+ smartFormat (helpThreadStatsResults .rawStats ()
185+ .get (AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS , Double .class )),
186+ smartFormat (helpThreadStatsResults .rawStats ()
187+ .get (MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS , Double .class )),
188+ smartFormat (helpThreadStatsResults .rawStats ()
189+ .get (MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS , Double .class ))),
190+ false );
191+ return helpThreadStatsEmbed ;
192+ }
151193
152- double rawResRate =
194+
195+ @ Override
196+ public void onSlashCommand (SlashCommandInteractionEvent event ) {
197+ long channelId = event .getChannel ().getIdLong ();
198+ Instant now = Instant .now ();
199+ long secondsSinceLastUsage = getSecondsSinceLastUsage (channelId , now );
200+ if (secondsSinceLastUsage != 0L ) {
201+ event
202+ .reply ("This command is on cooldown! Please wait " + secondsSinceLastUsage
203+ + " more seconds." )
204+ .setEphemeral (true )
205+ .queue ();
206+ return ;
207+ }
208+ event .deferReply ().queue ();
209+ cooldownCache .put (channelId , now );
210+ int daysBackOption = Optional .ofNullable (event .getOption (DURATION_OPTION ))
211+ .map (OptionMapping ::getAsInt )
212+ .orElse (1 );
213+
214+ Instant startDate = Instant .now ().minus (daysBackOption , ChronoUnit .DAYS );
215+
216+ getHelpThreadUsageStats (startDate ).ifPresentOrElse (stats -> {
217+ int totalCreated = stats .get (TOTAL_CREATED_FIELD , Integer .class );
218+ int openThreads = stats .get (OPEN_NOW_ALIAS , Integer .class );
219+ long ghostThreads = stats .get (GHOST_NOW_ALIAS , Number .class ).longValue ();
220+
221+ double helpThreadInteractionRate =
153222 totalCreated > 0 ? ((double ) (totalCreated - ghostThreads ) / totalCreated ) * 100
154223 : 0 ;
155224
156- String highVolumeTag = getTopTag (context , startDate , count ().desc ());
157- String highActivityTag =
158- getTopTag (context , startDate , avg (HELP_THREADS .MESSAGE_COUNT ).desc ());
159- String lowActivityTag =
160- getTopTag (context , startDate , avg (HELP_THREADS .MESSAGE_COUNT ).asc ());
161-
162- String peakHourRange = getPeakHour (context , startDate );
163-
164- EmbedBuilder embed = new EmbedBuilder ()
165- .setTitle (EMOJI_CHART + " Help Thread Stats (Last " + days + " Days)" )
166- .setColor (getStatusColor (totalCreated , ghostThreads ))
167- .setTimestamp (Instant .now ())
168- .setDescription (EMBED_BLANK_LINE )
169- .setFooter ("Together Java Community Stats" ,
170- Objects .requireNonNull (event .getGuild ()).getIconUrl ());
171-
172- embed .addField (EMOJI_MEMO + WHITESPACE + "THREAD ACTIVITY" ,
173- "Created: `%d`%nCurrently Open: `%d`%nResponse Rate: %.1f%%%nPeak Hours: `%s`"
174- .formatted (totalCreated , openThreads , rawResRate , peakHourRange ),
175- false );
176-
177- embed .addField (EMOJI_SPEECH_BUBBLE + WHITESPACE + "ENGAGEMENT" ,
178- "Avg Messages: `%s`%nAvg Helpers: `%s`%nUnanswered (Ghost): `%d`" .formatted (
179- formatDouble (Objects
180- .requireNonNull (statsRecord .get (AVERAGE_MESSAGE_COUNT_ALIAS ))),
181- formatDouble (Objects
182- .requireNonNull (statsRecord .get (AVERAGE_PARTICIPANTS_ALIAS ))),
183- ghostThreads ),
184- false );
185-
186- embed .addField (EMOJI_LABEL + WHITESPACE + "TAG ACTIVITY" ,
187- "Most Used: `%s`%nMost Active: `%s`%nNeeds Love: `%s`" .formatted (highVolumeTag ,
188- highActivityTag , lowActivityTag ),
189- false );
190-
191- embed .addField (EMOJI_LIGHTNING + WHITESPACE + "RESOLUTION SPEED" ,
192- "Average: `%s`%nFastest: `%s`%nSlowest: `%s`" .formatted (
193- smartFormat (statsRecord .get (AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS ,
194- Double .class )),
195- smartFormat (statsRecord .get (MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS ,
196- Double .class )),
197- smartFormat (statsRecord .get (MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS ,
198- Double .class ))),
199- false );
200-
201- event .getHook ().editOriginalEmbeds (embed .build ()).queue ();
202- return null ;
203- });
225+ String highVolumeTag = getTopTag (startDate , count ().desc ());
226+ String highActivityTag = getTopTag (startDate , avg (HELP_THREADS .MESSAGE_COUNT ).desc ());
227+ String lowActivityTag = getTopTag (startDate , avg (HELP_THREADS .MESSAGE_COUNT ).asc ());
228+
229+ String peakHourRange = getPeakHour (startDate );
230+ StatsReportData fetchedStats = new StatsReportData (daysBackOption , totalCreated ,
231+ openThreads , ghostThreads , helpThreadInteractionRate , highVolumeTag ,
232+ highActivityTag , lowActivityTag , peakHourRange , stats );
233+ EmbedBuilder helpThreadStatsEmbed =
234+ buildStatsEmbed (fetchedStats , event .getGuild ().getIconUrl (), daysBackOption );
235+
236+ event .getHook ().editOriginalEmbeds (helpThreadStatsEmbed .build ()).queue ();
237+
238+ }, () -> event .getHook ()
239+ .editOriginal ("No stats available for the last " + daysBackOption + " days." )
240+ .queue ());
204241 }
205242
206- private static Color getStatusColor (int totalCreated , long ghostThreads ) {
207- double rawResRate =
208- totalCreated > 0 ? ((double ) (totalCreated - ghostThreads ) / totalCreated ) * 100
209- : -1 ;
243+ private static Color getStatusColor (int totalHelpThreadsCreated , long ghostThreads ) {
244+ double helpThreadInteractionRate = totalHelpThreadsCreated > 0
245+ ? ((double ) (totalHelpThreadsCreated - ghostThreads ) / totalHelpThreadsCreated )
246+ * 100
247+ : -1 ;
210248
211- if (rawResRate >= 70 )
249+ if (helpThreadInteractionRate >= 70 )
212250 return Color .GREEN ;
213- if (rawResRate >= 30 )
251+ if (helpThreadInteractionRate >= 30 )
214252 return Color .YELLOW ;
215- if (rawResRate >= 0 )
253+ if (helpThreadInteractionRate >= 0 )
216254 return Color .RED ;
217255 return Color .GRAY ;
218256 }
219257
220- private String getTopTag (DSLContext context , Instant start , OrderField <?> order ) {
221- return context .select (HELP_THREADS .TAGS )
258+ private String getTopTag (Instant start , OrderField <?> order ) {
259+ return database . read ( context -> context .select (HELP_THREADS .TAGS )
222260 .from (HELP_THREADS )
223261 .where (HELP_THREADS .CREATED_AT .ge (start ))
224262 .and (HELP_THREADS .TAGS .ne ("none" ))
225263 .groupBy (HELP_THREADS .TAGS )
226264 .orderBy (order )
227265 .limit (1 )
228266 .fetchOptional (HELP_THREADS .TAGS )
229- .orElse ("N/A" );
267+ .orElse ("N/A" )) ;
230268 }
231269
232- private String getPeakHour (DSLContext context , Instant start ) {
233- return context .select (field ("strftime('%H', {0})" , String .class , HELP_THREADS .CREATED_AT ))
270+ private String getPeakHour (Instant start ) {
271+ return database .read (context -> context
272+ .select (field ("strftime('%H', {0})" , String .class , HELP_THREADS .CREATED_AT ))
234273 .from (HELP_THREADS )
235274 .where (HELP_THREADS .CREATED_AT .ge (start ))
236275 .groupBy (field ("strftime('%H', {0})" , String .class , HELP_THREADS .CREATED_AT ))
@@ -241,12 +280,14 @@ private String getPeakHour(DSLContext context, Instant start) {
241280 int h = Integer .parseInt (hour );
242281 return "%02d:00 - %02d:00 UTC" .formatted (h , (h + 1 ) % 24 );
243282 })
244- .orElse ("N/A" );
283+ .orElse ("N/A" )) ;
245284 }
246285
247- private String smartFormat (Double seconds ) {
248- if (seconds < 0 )
286+ private String smartFormat (@ Nullable Double seconds ) {
287+ if (seconds == null || seconds < 0 ) {
249288 return "N/A" ;
289+ }
290+
250291 if (seconds < 60 )
251292 return "%.0f secs" .formatted (seconds );
252293 if (seconds < 3600 )
@@ -256,6 +297,7 @@ private String smartFormat(Double seconds) {
256297 return "%.1f days" .formatted (seconds / 86400.0 );
257298 }
258299
300+
259301 private String formatDouble (Object val ) {
260302 return val instanceof Number num ? "%.2f" .formatted (num .doubleValue ()) : "0.00" ;
261303 }
0 commit comments