@zumito-team/analytics-module 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,34 @@
8
8
  npm install @zumito-team/analytics-module
9
9
  ```
10
10
 
11
- Add to your `zumito.config.ts` (note the `/dist` suffix):
11
+ Add to your `zumito.config.ts`:
12
+
13
+ ### Modules (recommended)
14
+
15
+ ```ts
16
+ import { defineConfig } from 'zumito-framework';
17
+ import { analyticsModule } from '@zumito-team/analytics-module';
18
+
19
+ export default defineConfig({
20
+ modules: [
21
+ analyticsModule({
22
+ defaultTrackCommandPerformance: true,
23
+ }),
24
+ ]
25
+ });
26
+ ```
27
+
28
+ Or with plain string:
29
+
30
+ ```ts
31
+ import { defineConfig } from 'zumito-framework';
32
+
33
+ export default defineConfig({
34
+ modules: ['@zumito-team/analytics-module']
35
+ });
36
+ ```
37
+
38
+ ### Legacy bundles
12
39
 
13
40
  ```ts
14
41
  {
package/dist/config.d.ts CHANGED
@@ -7,5 +7,6 @@ export declare class AnalyticsModuleConfig {
7
7
  static defaultTrackCommands: boolean;
8
8
  static defaultTrackCommandPerformance: boolean;
9
9
  static defaultTrackPerChannelVoice: boolean;
10
+ static defaultTrackPerChannelMessages: boolean;
10
11
  static configure(opts: Partial<typeof AnalyticsModuleConfig>): void;
11
12
  }
package/dist/config.js CHANGED
@@ -11,3 +11,4 @@ AnalyticsModuleConfig.defaultTrackMembers = true;
11
11
  AnalyticsModuleConfig.defaultTrackCommands = true;
12
12
  AnalyticsModuleConfig.defaultTrackCommandPerformance = false;
13
13
  AnalyticsModuleConfig.defaultTrackPerChannelVoice = false;
14
+ AnalyticsModuleConfig.defaultTrackPerChannelMessages = false;
@@ -13,6 +13,6 @@ export class MessageCreate extends FrameworkEvent {
13
13
  return;
14
14
  if (message.author?.bot)
15
15
  return;
16
- await this.collector.recordMessage(message.guildId);
16
+ await this.collector.recordMessage(message.guildId, message.channelId, message.author?.id);
17
17
  }
18
18
  }
package/dist/index.d.ts CHANGED
@@ -8,10 +8,23 @@ export declare class AnalyticsModule extends Module {
8
8
  initialize(): Promise<void>;
9
9
  onAllReady(): Promise<void>;
10
10
  }
11
+ export interface AnalyticsModuleEntryConfig {
12
+ defaultRetentionDays?: number;
13
+ cleanupIntervalHours?: number;
14
+ defaultTrackMessages?: boolean;
15
+ defaultTrackVoice?: boolean;
16
+ defaultTrackMembers?: boolean;
17
+ defaultTrackCommands?: boolean;
18
+ defaultTrackCommandPerformance?: boolean;
19
+ defaultTrackPerChannelVoice?: boolean;
20
+ defaultTrackPerChannelMessages?: boolean;
21
+ }
22
+ export declare const analyticsModule: (config?: AnalyticsModuleEntryConfig) => import("zumito-framework").ModuleEntry;
11
23
  export { AnalyticsCollector } from './services/AnalyticsCollector.js';
12
24
  export type { CommandExecutedPayload } from './services/AnalyticsCollector.js';
13
25
  export { AnalyticsModuleConfig } from './config.js';
14
26
  export { GuildDailyStats } from './models/GuildDailyStats.js';
15
27
  export { CommandDailyStats } from './models/CommandDailyStats.js';
16
28
  export { VoiceChannelDailyStats } from './models/VoiceChannelDailyStats.js';
29
+ export { ChannelMessageStats } from './models/ChannelMessageStats.js';
17
30
  export { GuildAnalyticsConfig } from './models/GuildAnalyticsConfig.js';
package/dist/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import 'reflect-metadata';
2
- import { Module, ServiceContainer } from 'zumito-framework';
2
+ import { Module, ServiceContainer, createModuleEntry } from 'zumito-framework';
3
3
  import { AnalyticsCollector } from './services/AnalyticsCollector.js';
4
+ import { AnalyticsModuleConfig } from './config.js';
4
5
  export class AnalyticsModule extends Module {
5
6
  constructor(modulePath = import.meta.url) {
6
7
  super(modulePath);
7
8
  ServiceContainer.addService(AnalyticsCollector, [], true);
9
+ AnalyticsModuleConfig.configure(this.moduleConfig);
8
10
  }
9
11
  async initialize() {
10
12
  await super.initialize();
@@ -84,9 +86,11 @@ export class AnalyticsModule extends Module {
84
86
  AnalyticsModule.moduleName = 'analytics-module';
85
87
  AnalyticsModule.dependencies = [];
86
88
  AnalyticsModule.optionalDependencies = ['admin-module', 'user-panel-module'];
89
+ export const analyticsModule = createModuleEntry(import.meta.url);
87
90
  export { AnalyticsCollector } from './services/AnalyticsCollector.js';
88
91
  export { AnalyticsModuleConfig } from './config.js';
89
92
  export { GuildDailyStats } from './models/GuildDailyStats.js';
90
93
  export { CommandDailyStats } from './models/CommandDailyStats.js';
91
94
  export { VoiceChannelDailyStats } from './models/VoiceChannelDailyStats.js';
95
+ export { ChannelMessageStats } from './models/ChannelMessageStats.js';
92
96
  export { GuildAnalyticsConfig } from './models/GuildAnalyticsConfig.js';
@@ -0,0 +1,8 @@
1
+ export declare class ChannelMessageStats {
2
+ id: string;
3
+ guild_id: string;
4
+ channel_id: string;
5
+ date: string;
6
+ message_count: number;
7
+ unique_authors: number;
8
+ }
@@ -0,0 +1,31 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { Collection, Field } from 'zumito-framework';
8
+ let ChannelMessageStats = class ChannelMessageStats {
9
+ };
10
+ __decorate([
11
+ Field({ type: 'string', primary: true, unique: true })
12
+ ], ChannelMessageStats.prototype, "id", void 0);
13
+ __decorate([
14
+ Field({ type: 'string' })
15
+ ], ChannelMessageStats.prototype, "guild_id", void 0);
16
+ __decorate([
17
+ Field({ type: 'string' })
18
+ ], ChannelMessageStats.prototype, "channel_id", void 0);
19
+ __decorate([
20
+ Field({ type: 'string' })
21
+ ], ChannelMessageStats.prototype, "date", void 0);
22
+ __decorate([
23
+ Field({ type: 'number', default: 0 })
24
+ ], ChannelMessageStats.prototype, "message_count", void 0);
25
+ __decorate([
26
+ Field({ type: 'number', default: 0 })
27
+ ], ChannelMessageStats.prototype, "unique_authors", void 0);
28
+ ChannelMessageStats = __decorate([
29
+ Collection({ name: 'analytics_channel_message_stats' })
30
+ ], ChannelMessageStats);
31
+ export { ChannelMessageStats };
@@ -0,0 +1,9 @@
1
+ export declare class CommandExecuted {
2
+ id: string;
3
+ guild_id: string;
4
+ command_name: string;
5
+ type: 'slash' | 'prefix';
6
+ execution_time_ms: number;
7
+ error: boolean;
8
+ executed_at: string;
9
+ }
@@ -0,0 +1,34 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { Collection, Field } from 'zumito-framework';
8
+ let CommandExecuted = class CommandExecuted {
9
+ };
10
+ __decorate([
11
+ Field({ type: 'string', primary: true, unique: true })
12
+ ], CommandExecuted.prototype, "id", void 0);
13
+ __decorate([
14
+ Field({ type: 'string' })
15
+ ], CommandExecuted.prototype, "guild_id", void 0);
16
+ __decorate([
17
+ Field({ type: 'string' })
18
+ ], CommandExecuted.prototype, "command_name", void 0);
19
+ __decorate([
20
+ Field({ type: 'string' })
21
+ ], CommandExecuted.prototype, "type", void 0);
22
+ __decorate([
23
+ Field({ type: 'number', default: 0 })
24
+ ], CommandExecuted.prototype, "execution_time_ms", void 0);
25
+ __decorate([
26
+ Field({ type: 'boolean', default: false })
27
+ ], CommandExecuted.prototype, "error", void 0);
28
+ __decorate([
29
+ Field({ type: 'string' })
30
+ ], CommandExecuted.prototype, "executed_at", void 0);
31
+ CommandExecuted = __decorate([
32
+ Collection({ name: 'analytics_commands_executed' })
33
+ ], CommandExecuted);
34
+ export { CommandExecuted };
@@ -7,6 +7,7 @@ export declare class GuildAnalyticsConfig {
7
7
  track_commands: boolean;
8
8
  track_command_performance: boolean;
9
9
  track_per_channel_voice: boolean;
10
+ track_per_channel_messages: boolean;
10
11
  retention_days: number;
11
12
  public_stats_page: boolean;
12
13
  }
@@ -31,6 +31,9 @@ __decorate([
31
31
  __decorate([
32
32
  Field({ type: 'boolean', default: false })
33
33
  ], GuildAnalyticsConfig.prototype, "track_per_channel_voice", void 0);
34
+ __decorate([
35
+ Field({ type: 'boolean', default: false })
36
+ ], GuildAnalyticsConfig.prototype, "track_per_channel_messages", void 0);
34
37
  __decorate([
35
38
  Field({ type: 'number' })
36
39
  ], GuildAnalyticsConfig.prototype, "retention_days", void 0);
@@ -18,9 +18,11 @@ export class AdminAnalyticsCommands extends Route {
18
18
  return res.redirect('/admin/login');
19
19
  const daysBack = parseInt(req.query.days) || 7;
20
20
  const commandsPerDay = await this.collector.getCommandsPerDay(null, daysBack);
21
+ const commandsPerDayByType = await this.collector.getCommandsPerDayByType(null, daysBack);
22
+ const commandsByType = await this.collector.getCommandsByType(null, daysBack);
21
23
  const topCommands = await this.collector.getTopCommands(null, daysBack, 15);
22
24
  const slowestCommands = await this.collector.getSlowestCommands(null, daysBack, 10);
23
- const content = await ejs.renderFile(path.resolve(__dirname, '../views/admin-analytics-commands.ejs'), { commandsPerDay, topCommands, slowestCommands, daysBack });
25
+ const content = await ejs.renderFile(path.resolve(__dirname, '../views/admin-analytics-commands.ejs'), { commandsPerDay, commandsPerDayByType, commandsByType, topCommands, slowestCommands, daysBack });
24
26
  const { AdminViewService } = await import('@zumito-team/admin-module/services/AdminViewService.js');
25
27
  const view = ServiceContainer.getService(AdminViewService);
26
28
  const html = await view.render({
@@ -37,10 +37,10 @@ export class UserPanelAnalyticsCommands extends Route {
37
37
  const langMgr = ServiceContainer.getService(UserPanelLanguageManager);
38
38
  const { t } = langMgr.getLanguageVariables(req, res);
39
39
  const commandsPerDay = await this.collector.getCommandsPerDay(guildId, daysBack);
40
+ const commandsPerDayByType = await this.collector.getCommandsPerDayByType(guildId, daysBack);
41
+ const commandsByType = await this.collector.getCommandsByType(guildId, daysBack);
40
42
  const topCommands = await this.collector.getTopCommands(guildId, daysBack, 15);
41
- const slowestCommands = await this.collector.getSlowestCommands(guildId, daysBack, 10);
42
- const config = await this.collector.getConfig(guildId);
43
- const content = await ejs.renderFile(path.resolve(__dirname, '../views/user-analytics-commands.ejs'), { commandsPerDay, topCommands, slowestCommands: config.track_command_performance ? slowestCommands : null, t, daysBack });
43
+ const content = await ejs.renderFile(path.resolve(__dirname, '../views/user-analytics-commands.ejs'), { commandsPerDay, commandsPerDayByType, commandsByType, topCommands, t, daysBack });
44
44
  const view = ServiceContainer.getService(UserPanelViewService);
45
45
  const html = await view.render({ content, reqPath: req.path, req, res });
46
46
  res.send(html);
@@ -43,7 +43,8 @@ export class UserPanelAnalyticsMessages extends Route {
43
43
  totalMessages += s.message_count;
44
44
  messagesPerDay.push({ date: s.date, count: s.message_count });
45
45
  }
46
- const content = await ejs.renderFile(path.resolve(__dirname, '../views/user-analytics-messages.ejs'), { totalMessages, messagesPerDay, t, daysBack });
46
+ const channelMessages = await this.collector.getChannelMessageStats(guildId, daysBack);
47
+ const content = await ejs.renderFile(path.resolve(__dirname, '../views/user-analytics-messages.ejs'), { totalMessages, messagesPerDay, channelMessages, t, daysBack });
47
48
  const view = ServiceContainer.getService(UserPanelViewService);
48
49
  const html = await view.render({ content, reqPath: req.path, req, res });
49
50
  res.send(html);
@@ -1,7 +1,7 @@
1
1
  import { GuildDailyStats } from '../models/GuildDailyStats.js';
2
- import { CommandDailyStats } from '../models/CommandDailyStats.js';
3
2
  import { VoiceChannelDailyStats } from '../models/VoiceChannelDailyStats.js';
4
3
  import { GuildAnalyticsConfig } from '../models/GuildAnalyticsConfig.js';
4
+ import { ChannelMessageStats } from '../models/ChannelMessageStats.js';
5
5
  export interface CommandExecutedPayload {
6
6
  guildId: string;
7
7
  commandName: string;
@@ -13,11 +13,13 @@ export declare class AnalyticsCollector {
13
13
  private db;
14
14
  private voiceSessions;
15
15
  private dailyVoiceUsers;
16
+ private dailyMessageAuthors;
17
+ private dailyChannelMessageAuthors;
16
18
  private cleanupTimer;
17
19
  constructor(db?: any);
18
20
  private repo;
19
21
  private ensuredConfig;
20
- recordMessage(guildId: string): Promise<void>;
22
+ recordMessage(guildId: string, channelId?: string, authorId?: string): Promise<void>;
21
23
  recordMemberJoin(guildId: string): Promise<void>;
22
24
  recordMemberLeave(guildId: string): Promise<void>;
23
25
  recordVoiceJoin(guildId: string, channelId: string, userId: string): Promise<void>;
@@ -43,15 +45,32 @@ export declare class AnalyticsCollector {
43
45
  date: string;
44
46
  count: number;
45
47
  }[]>;
48
+ private getCommandExecutedRows;
49
+ private getCommandDailyStatsRows;
46
50
  getCommandsPerDay(guildId: string | null, daysBack: number): Promise<{
47
51
  date: string;
48
52
  count: number;
49
53
  }[]>;
50
- getTopCommands(guildId: string | null, daysBack: number, limit?: number): Promise<CommandDailyStats[]>;
51
- getSlowestCommands(guildId: string | null, daysBack: number, limit?: number): Promise<(CommandDailyStats & {
54
+ getCommandsPerDayByType(guildId: string | null, daysBack: number): Promise<{
55
+ date: string;
56
+ slash: number;
57
+ prefix: number;
58
+ }[]>;
59
+ getCommandsByType(guildId: string | null, daysBack: number): Promise<{
60
+ slash: number;
61
+ prefix: number;
62
+ }>;
63
+ getTopCommands(guildId: string | null, daysBack: number, limit?: number): Promise<{
64
+ command_name: string;
65
+ usage_count: number;
66
+ }[]>;
67
+ getSlowestCommands(guildId: string | null, daysBack: number, limit?: number): Promise<{
68
+ command_name: string;
52
69
  avgExecutionTimeMs: number;
53
- })[]>;
70
+ total_executions: number;
71
+ }[]>;
54
72
  getVoiceChannelStats(guildId: string, daysBack: number): Promise<VoiceChannelDailyStats[]>;
73
+ getChannelMessageStats(guildId: string, daysBack: number): Promise<ChannelMessageStats[]>;
55
74
  getConfig(guildId: string): Promise<GuildAnalyticsConfig>;
56
75
  updateConfig(guildId: string, partial: Partial<GuildAnalyticsConfig>): Promise<void>;
57
76
  runCleanup(): Promise<void>;
@@ -1,8 +1,10 @@
1
1
  import { DatabaseManager, ServiceContainer } from 'zumito-framework';
2
2
  import { GuildDailyStats } from '../models/GuildDailyStats.js';
3
3
  import { CommandDailyStats } from '../models/CommandDailyStats.js';
4
+ import { CommandExecuted } from '../models/CommandExecuted.js';
4
5
  import { VoiceChannelDailyStats } from '../models/VoiceChannelDailyStats.js';
5
6
  import { GuildAnalyticsConfig } from '../models/GuildAnalyticsConfig.js';
7
+ import { ChannelMessageStats } from '../models/ChannelMessageStats.js';
6
8
  import { AnalyticsModuleConfig } from '../config.js';
7
9
  function today() {
8
10
  return new Date().toISOString().slice(0, 10);
@@ -12,6 +14,8 @@ export class AnalyticsCollector {
12
14
  this.db = db;
13
15
  this.voiceSessions = new Map();
14
16
  this.dailyVoiceUsers = new Map();
17
+ this.dailyMessageAuthors = new Map();
18
+ this.dailyChannelMessageAuthors = new Map();
15
19
  this.cleanupTimer = null;
16
20
  }
17
21
  repo(name) {
@@ -32,17 +36,55 @@ export class AnalyticsCollector {
32
36
  track_commands: AnalyticsModuleConfig.defaultTrackCommands,
33
37
  track_command_performance: AnalyticsModuleConfig.defaultTrackCommandPerformance,
34
38
  track_per_channel_voice: AnalyticsModuleConfig.defaultTrackPerChannelVoice,
39
+ track_per_channel_messages: AnalyticsModuleConfig.defaultTrackPerChannelMessages,
35
40
  retention_days: AnalyticsModuleConfig.defaultRetentionDays,
36
41
  public_stats_page: false,
37
42
  };
38
43
  await repo.insert(defaults);
39
44
  return defaults;
40
45
  }
41
- async recordMessage(guildId) {
46
+ async recordMessage(guildId, channelId, authorId) {
42
47
  const config = await this.ensuredConfig(guildId);
43
48
  if (!config.enabled || !config.track_messages)
44
49
  return;
45
50
  await this.upsertGuildStats(guildId, { message_count: 1 });
51
+ // Track unique authors per day
52
+ if (authorId) {
53
+ const authorKey = `${guildId}_${today()}`;
54
+ if (!this.dailyMessageAuthors.has(authorKey)) {
55
+ this.dailyMessageAuthors.set(authorKey, new Set());
56
+ }
57
+ this.dailyMessageAuthors.get(authorKey).add(authorId);
58
+ }
59
+ // Track per-channel stats
60
+ if (channelId && config.track_per_channel_messages) {
61
+ const date = today();
62
+ const chanKey = `${guildId}_${channelId}_${date}`;
63
+ const repo = this.repo(ChannelMessageStats);
64
+ const existing = await repo.findOne({ id: chanKey });
65
+ if (authorId) {
66
+ if (!this.dailyChannelMessageAuthors.has(chanKey)) {
67
+ this.dailyChannelMessageAuthors.set(chanKey, new Set());
68
+ }
69
+ this.dailyChannelMessageAuthors.get(chanKey).add(authorId);
70
+ }
71
+ if (existing) {
72
+ await repo.update({ id: chanKey }, {
73
+ message_count: existing.message_count + 1,
74
+ unique_authors: this.dailyChannelMessageAuthors.get(chanKey)?.size || 0,
75
+ });
76
+ }
77
+ else {
78
+ await repo.insert({
79
+ id: chanKey,
80
+ guild_id: guildId,
81
+ channel_id: channelId,
82
+ date,
83
+ message_count: 1,
84
+ unique_authors: 0,
85
+ });
86
+ }
87
+ }
46
88
  }
47
89
  async recordMemberJoin(guildId) {
48
90
  const config = await this.ensuredConfig(guildId);
@@ -123,29 +165,17 @@ export class AnalyticsCollector {
123
165
  return;
124
166
  await this.upsertGuildStats(payload.guildId, { command_count: 1 });
125
167
  const date = today();
126
- const id = `${payload.guildId}_${payload.commandName}_${date}`;
127
- const repo = this.repo(CommandDailyStats);
128
- const existing = await repo.findOne({ id });
129
- const executionTime = config.track_command_performance ? payload.executionTimeMs : 0;
130
- const errorAdd = payload.success ? 0 : 1;
131
- if (existing) {
132
- await repo.update({ id }, {
133
- usage_count: existing.usage_count + 1,
134
- total_execution_time_ms: existing.total_execution_time_ms + executionTime,
135
- error_count: existing.error_count + errorAdd,
136
- });
137
- }
138
- else {
139
- await repo.insert({
140
- id,
141
- guild_id: payload.guildId,
142
- command_name: payload.commandName,
143
- date,
144
- usage_count: 1,
145
- total_execution_time_ms: executionTime,
146
- error_count: errorAdd,
147
- });
148
- }
168
+ const id = `${payload.guildId}_${payload.commandName}_${Date.now()}`;
169
+ const repo = this.repo(CommandExecuted);
170
+ await repo.insert({
171
+ id,
172
+ guild_id: payload.guildId,
173
+ command_name: payload.commandName,
174
+ type: payload.type,
175
+ execution_time_ms: payload.executionTimeMs,
176
+ error: !payload.success,
177
+ executed_at: date,
178
+ });
149
179
  }
150
180
  async recordMemberCount(guildId, memberCount) {
151
181
  const config = await this.ensuredConfig(guildId);
@@ -266,115 +296,124 @@ export class AnalyticsCollector {
266
296
  .map(([date, count]) => ({ date, count }))
267
297
  .sort((a, b) => a.date.localeCompare(b.date));
268
298
  }
269
- async getCommandsPerDay(guildId, daysBack) {
270
- const repo = this.repo(GuildDailyStats);
299
+ async getCommandExecutedRows(guildId, daysBack) {
300
+ const repo = this.repo(CommandExecuted);
271
301
  const dateMin = this.dateDaysAgo(daysBack);
272
- let rows;
302
+ let rows = await repo.query()
303
+ .where('executed_at', 'gte', dateMin)
304
+ .exec();
273
305
  if (guildId) {
274
- rows = await repo.query()
275
- .where('guild_id', 'eq', guildId)
276
- .where('date', 'gte', dateMin)
277
- .sort('date', 'asc')
278
- .exec();
306
+ rows = rows.filter(r => r.guild_id === guildId);
279
307
  }
280
- else {
281
- rows = await repo.query()
282
- .where('date', 'gte', dateMin)
283
- .sort('date', 'asc')
284
- .exec();
308
+ return rows;
309
+ }
310
+ async getCommandDailyStatsRows(guildId, daysBack) {
311
+ const repo = this.repo(CommandDailyStats);
312
+ const dateMin = this.dateDaysAgo(daysBack);
313
+ let rows = await repo.query()
314
+ .where('date', 'gte', dateMin)
315
+ .exec();
316
+ if (guildId) {
317
+ rows = rows.filter(r => r.guild_id === guildId);
285
318
  }
319
+ return rows;
320
+ }
321
+ async getCommandsPerDay(guildId, daysBack) {
322
+ const newRows = await this.getCommandExecutedRows(guildId, daysBack);
323
+ const oldRows = await this.getCommandDailyStatsRows(guildId, daysBack);
286
324
  const byDate = new Map();
287
- for (const row of rows) {
288
- byDate.set(row.date, (byDate.get(row.date) || 0) + row.command_count);
325
+ for (const row of newRows) {
326
+ byDate.set(row.executed_at, (byDate.get(row.executed_at) || 0) + 1);
327
+ }
328
+ for (const row of oldRows) {
329
+ byDate.set(row.date, (byDate.get(row.date) || 0) + row.usage_count);
289
330
  }
290
331
  return Array.from(byDate.entries())
291
332
  .map(([date, count]) => ({ date, count }))
292
333
  .sort((a, b) => a.date.localeCompare(b.date));
293
334
  }
294
- async getTopCommands(guildId, daysBack, limit = 10) {
295
- const repo = this.repo(CommandDailyStats);
296
- const dateMin = this.dateDaysAgo(daysBack);
297
- let rows;
298
- if (guildId) {
299
- rows = await repo.query()
300
- .where('guild_id', 'eq', guildId)
301
- .where('date', 'gte', dateMin)
302
- .exec();
335
+ async getCommandsPerDayByType(guildId, daysBack) {
336
+ const newRows = await this.getCommandExecutedRows(guildId, daysBack);
337
+ const oldRows = await this.getCommandDailyStatsRows(guildId, daysBack);
338
+ const byDate = new Map();
339
+ for (const row of newRows) {
340
+ const entry = byDate.get(row.executed_at) || { slash: 0, prefix: 0 };
341
+ if (row.type === 'slash')
342
+ entry.slash += 1;
343
+ else
344
+ entry.prefix += 1;
345
+ byDate.set(row.executed_at, entry);
303
346
  }
304
- else {
305
- rows = await repo.query()
306
- .where('date', 'gte', dateMin)
307
- .exec();
347
+ for (const row of oldRows) {
348
+ const entry = byDate.get(row.date) || { slash: 0, prefix: 0 };
349
+ entry.prefix += row.usage_count;
350
+ byDate.set(row.date, entry);
351
+ }
352
+ return Array.from(byDate.entries())
353
+ .map(([date, counts]) => ({ date, ...counts }))
354
+ .sort((a, b) => a.date.localeCompare(b.date));
355
+ }
356
+ async getCommandsByType(guildId, daysBack) {
357
+ const newRows = await this.getCommandExecutedRows(guildId, daysBack);
358
+ const oldRows = await this.getCommandDailyStatsRows(guildId, daysBack);
359
+ let slash = 0;
360
+ let prefix = 0;
361
+ for (const row of newRows) {
362
+ if (row.type === 'slash')
363
+ slash++;
364
+ else
365
+ prefix++;
308
366
  }
367
+ for (const row of oldRows) {
368
+ prefix += row.usage_count;
369
+ }
370
+ return { slash, prefix };
371
+ }
372
+ async getTopCommands(guildId, daysBack, limit = 10) {
373
+ const newRows = await this.getCommandExecutedRows(guildId, daysBack);
374
+ const oldRows = await this.getCommandDailyStatsRows(guildId, daysBack);
309
375
  const aggregated = new Map();
310
- for (const row of rows) {
311
- const key = row.command_name;
312
- if (aggregated.has(key)) {
313
- const existing = aggregated.get(key);
314
- existing.usage_count += row.usage_count;
315
- existing.total_execution_time_ms += row.total_execution_time_ms;
316
- existing.error_count += row.error_count;
317
- }
318
- else {
319
- aggregated.set(key, {
320
- id: key,
321
- guild_id: guildId || 'global',
322
- command_name: row.command_name,
323
- date: '',
324
- usage_count: row.usage_count,
325
- total_execution_time_ms: row.total_execution_time_ms,
326
- error_count: row.error_count,
327
- });
328
- }
376
+ for (const row of newRows) {
377
+ aggregated.set(row.command_name, (aggregated.get(row.command_name) || 0) + 1);
378
+ }
379
+ for (const row of oldRows) {
380
+ aggregated.set(row.command_name, (aggregated.get(row.command_name) || 0) + row.usage_count);
329
381
  }
330
- return Array.from(aggregated.values())
382
+ return Array.from(aggregated.entries())
383
+ .map(([command_name, usage_count]) => ({ command_name, usage_count }))
331
384
  .sort((a, b) => b.usage_count - a.usage_count)
332
385
  .slice(0, limit);
333
386
  }
334
387
  async getSlowestCommands(guildId, daysBack, limit = 10) {
335
- const repo = this.repo(CommandDailyStats);
336
- const dateMin = this.dateDaysAgo(daysBack);
337
- let rows;
338
- if (guildId) {
339
- rows = await repo.query()
340
- .where('guild_id', 'eq', guildId)
341
- .where('date', 'gte', dateMin)
342
- .exec();
343
- }
344
- else {
345
- rows = await repo.query()
346
- .where('date', 'gte', dateMin)
347
- .exec();
348
- }
388
+ const newRows = await this.getCommandExecutedRows(guildId, daysBack);
389
+ const oldRows = await this.getCommandDailyStatsRows(guildId, daysBack);
349
390
  const aggregated = new Map();
350
- for (const row of rows) {
351
- const key = row.command_name;
352
- if (aggregated.has(key)) {
353
- const e = aggregated.get(key);
354
- e.usage_count += row.usage_count;
355
- e.totalTime += row.total_execution_time_ms;
356
- e.errors += row.error_count;
391
+ for (const row of newRows) {
392
+ const existing = aggregated.get(row.command_name);
393
+ if (existing) {
394
+ existing.totalTime += row.execution_time_ms;
395
+ existing.count += 1;
357
396
  }
358
397
  else {
359
- aggregated.set(key, {
360
- usage_count: row.usage_count,
361
- totalTime: row.total_execution_time_ms,
362
- errors: row.error_count,
363
- });
398
+ aggregated.set(row.command_name, { totalTime: row.execution_time_ms, count: 1 });
399
+ }
400
+ }
401
+ for (const row of oldRows) {
402
+ const existing = aggregated.get(row.command_name);
403
+ if (existing) {
404
+ existing.totalTime += row.total_execution_time_ms;
405
+ existing.count += row.usage_count;
406
+ }
407
+ else {
408
+ aggregated.set(row.command_name, { totalTime: row.total_execution_time_ms, count: row.usage_count });
364
409
  }
365
410
  }
366
411
  return Array.from(aggregated.entries())
367
- .map(([name, data]) => ({
368
- id: name,
369
- guild_id: guildId || 'global',
370
- command_name: name,
371
- date: '',
372
- usage_count: data.usage_count,
373
- total_execution_time_ms: data.totalTime,
374
- error_count: data.errors,
375
- avgExecutionTimeMs: data.usage_count > 0 ? Math.round(data.totalTime / data.usage_count) : 0,
412
+ .map(([command_name, data]) => ({
413
+ command_name,
414
+ avgExecutionTimeMs: data.count > 0 ? Math.round(data.totalTime / data.count) : 0,
415
+ total_executions: data.count,
376
416
  }))
377
- .filter(c => c.total_execution_time_ms > 0)
378
417
  .sort((a, b) => b.avgExecutionTimeMs - a.avgExecutionTimeMs)
379
418
  .slice(0, limit);
380
419
  }
@@ -387,6 +426,15 @@ export class AnalyticsCollector {
387
426
  .sort('date', 'asc')
388
427
  .exec();
389
428
  }
429
+ async getChannelMessageStats(guildId, daysBack) {
430
+ const repo = this.repo(ChannelMessageStats);
431
+ const dateMin = this.dateDaysAgo(daysBack);
432
+ return repo.query()
433
+ .where('guild_id', 'eq', guildId)
434
+ .where('date', 'gte', dateMin)
435
+ .sort('date', 'asc')
436
+ .exec();
437
+ }
390
438
  // ── Config ───────────────────────────────────────────────────
391
439
  async getConfig(guildId) {
392
440
  return this.ensuredConfig(guildId);
@@ -434,6 +482,14 @@ export class AnalyticsCollector {
434
482
  for (const cmd of oldCmds) {
435
483
  await cmdRepo.delete({ id: cmd.id });
436
484
  }
485
+ const cmdExRepo = this.repo(CommandExecuted);
486
+ const oldExecs = await cmdExRepo.query()
487
+ .where('guild_id', 'eq', guildId)
488
+ .where('executed_at', 'lt', cutoffDate)
489
+ .exec();
490
+ for (const exec of oldExecs) {
491
+ await cmdExRepo.delete({ id: exec.id });
492
+ }
437
493
  const voiceRepo = this.repo(VoiceChannelDailyStats);
438
494
  const oldVoice = await voiceRepo.query()
439
495
  .where('guild_id', 'eq', guildId)
@@ -7,11 +7,25 @@
7
7
  <a href="?days=90" class="text-sm px-3 py-1 rounded bg-discord-dark-100 text-discord-foreground/70 hover:text-discord-foreground">90d</a>
8
8
  </div>
9
9
  </div>
10
+ <div class="grid grid-cols-2 gap-4 mb-6">
11
+ <div class="card text-center">
12
+ <div class="text-3xl font-bold text-blue-400"><%= commandsByType.slash %></div>
13
+ <div class="text-sm text-discord-foreground/60">Slash Commands</div>
14
+ </div>
15
+ <div class="card text-center">
16
+ <div class="text-3xl font-bold text-green-400"><%= commandsByType.prefix %></div>
17
+ <div class="text-sm text-discord-foreground/60">Prefix Commands</div>
18
+ </div>
19
+ </div>
10
20
  <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
11
21
  <div class="card">
12
22
  <h3 class="text-lg font-semibold mb-4 text-discord-foreground">Comandos por Dia</h3>
13
23
  <canvas id="chartCommands" width="400" height="200"></canvas>
14
24
  </div>
25
+ <div class="card">
26
+ <h3 class="text-lg font-semibold mb-4 text-discord-foreground">Comandos por Tipo</h3>
27
+ <canvas id="chartCommandsType" width="400" height="200"></canvas>
28
+ </div>
15
29
  <div class="card">
16
30
  <h3 class="text-lg font-semibold mb-4 text-discord-foreground">Top Comandos</h3>
17
31
  <canvas id="chartTop" width="400" height="200"></canvas>
@@ -30,15 +44,36 @@ new Chart(document.getElementById('chartCommands'), {
30
44
  type: 'bar',
31
45
  data: {
32
46
  labels: <%- JSON.stringify(commandsPerDay.map(v => v.date)) %>,
33
- datasets: [{ label: 'Comandos', data: <%- JSON.stringify(commandsPerDay.map(v => v.count)) %>, backgroundColor: '#5865f2', borderRadius: 4 }]
47
+ datasets: [{
48
+ label: 'Slash',
49
+ data: <%- JSON.stringify(commandsPerDayByType.map(v => v.slash)) %>,
50
+ backgroundColor: '#5865f2',
51
+ borderRadius: 4
52
+ }, {
53
+ label: 'Prefix',
54
+ data: <%- JSON.stringify(commandsPerDayByType.map(v => v.prefix)) %>,
55
+ backgroundColor: '#57f287',
56
+ borderRadius: 4
57
+ }]
58
+ },
59
+ options: { responsive: true, scales: { x: { stacked: true }, y: { stacked: true } } }
60
+ });
61
+ new Chart(document.getElementById('chartCommandsType'), {
62
+ type: 'doughnut',
63
+ data: {
64
+ labels: ['Slash', 'Prefix'],
65
+ datasets: [{
66
+ data: [<%= commandsByType.slash %>, <%= commandsByType.prefix %>],
67
+ backgroundColor: ['#5865f2', '#57f287']
68
+ }]
34
69
  },
35
- options: { responsive: true, plugins: { legend: { display: false } } }
70
+ options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
36
71
  });
37
72
  new Chart(document.getElementById('chartTop'), {
38
73
  type: 'bar',
39
74
  data: {
40
75
  labels: <%- JSON.stringify(topCommands.map(v => v.command_name)) %>,
41
- datasets: [{ label: 'Usos', data: <%- JSON.stringify(topCommands.map(v => v.usage_count)) %>, backgroundColor: topCommands.map((_, i) => 'rgba(88,101,242,' + Math.max(0.3, 1 - i * 0.08) + ')'), borderRadius: 4 }]
76
+ datasets: [{ label: 'Usos', data: <%- JSON.stringify(topCommands.map(v => v.usage_count)) %>, backgroundColor: <%- JSON.stringify(topCommands.map((_, i) => 'rgba(88,101,242,' + Math.max(0.3, 1 - i * 0.08) + ')')) %>, borderRadius: 4 }]
42
77
  },
43
78
  options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } } }
44
79
  });
@@ -7,6 +7,16 @@
7
7
  <a href="?days=90" class="text-sm px-3 py-1 rounded bg-discord-dark-100 text-discord-foreground/70 hover:text-discord-foreground">90d</a>
8
8
  </div>
9
9
  </div>
10
+ <div class="grid grid-cols-2 gap-4 mb-6">
11
+ <div class="card text-center">
12
+ <div class="text-3xl font-bold text-blue-400"><%= commandsByType.slash %></div>
13
+ <div class="text-sm text-discord-foreground/60">Slash Commands</div>
14
+ </div>
15
+ <div class="card text-center">
16
+ <div class="text-3xl font-bold text-green-400"><%= commandsByType.prefix %></div>
17
+ <div class="text-sm text-discord-foreground/60">Prefix Commands</div>
18
+ </div>
19
+ </div>
10
20
  <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
11
21
  <div class="card">
12
22
  <h3 class="text-lg font-semibold mb-4 text-discord-foreground"><%= t('analytics.commandsPerDay') %></h3>
@@ -17,39 +27,33 @@
17
27
  <canvas id="chartTop" width="400" height="200"></canvas>
18
28
  </div>
19
29
  </div>
20
- <% if (slowestCommands && slowestCommands.length > 0) { %>
21
- <div class="card mt-6">
22
- <h3 class="text-lg font-semibold mb-4 text-discord-foreground"><%= t('analytics.slowestCommands') %></h3>
23
- <canvas id="chartSlow" width="700" height="250"></canvas>
24
- </div>
25
- <% } %>
26
30
  </div>
27
31
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
28
32
  <script>
29
33
  new Chart(document.getElementById('chartCommands'), {
30
34
  type: 'bar',
31
35
  data: {
32
- labels: <%- JSON.stringify(commandsPerDay.map(v => v.date)) %>,
33
- datasets: [{ label: '<%= t("analytics.commands") %>', data: <%- JSON.stringify(commandsPerDay.map(v => v.count)) %>, backgroundColor: '#5865f2', borderRadius: 4 }]
36
+ labels: <%- JSON.stringify(commandsPerDayByType.map(v => v.date)) %>,
37
+ datasets: [{
38
+ label: 'Slash',
39
+ data: <%- JSON.stringify(commandsPerDayByType.map(v => v.slash)) %>,
40
+ backgroundColor: '#5865f2',
41
+ borderRadius: 4
42
+ }, {
43
+ label: 'Prefix',
44
+ data: <%- JSON.stringify(commandsPerDayByType.map(v => v.prefix)) %>,
45
+ backgroundColor: '#57f287',
46
+ borderRadius: 4
47
+ }]
34
48
  },
35
- options: { responsive: true, plugins: { legend: { display: false } } }
49
+ options: { responsive: true, scales: { x: { stacked: true }, y: { stacked: true } } }
36
50
  });
37
51
  new Chart(document.getElementById('chartTop'), {
38
52
  type: 'bar',
39
53
  data: {
40
54
  labels: <%- JSON.stringify(topCommands.map(v => v.command_name)) %>,
41
- datasets: [{ label: '<%= t("analytics.usageCount") %>', data: <%- JSON.stringify(topCommands.map(v => v.usage_count)) %>, backgroundColor: topCommands.map((_, i) => 'rgba(88,101,242,' + Math.max(0.3, 1 - i * 0.08) + ')'), borderRadius: 4 }]
55
+ datasets: [{ label: '<%= t("analytics.usageCount") %>', data: <%- JSON.stringify(topCommands.map(v => v.usage_count)) %>, backgroundColor: <%- JSON.stringify(topCommands.map((_, i) => 'rgba(88,101,242,' + Math.max(0.3, 1 - i * 0.08) + ')')) %>, borderRadius: 4 }]
42
56
  },
43
57
  options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } } }
44
58
  });
45
- <% if (slowestCommands && slowestCommands.length > 0) { %>
46
- new Chart(document.getElementById('chartSlow'), {
47
- type: 'bar',
48
- data: {
49
- labels: <%- JSON.stringify(slowestCommands.map(v => v.command_name)) %>,
50
- datasets: [{ label: 'ms', data: <%- JSON.stringify(slowestCommands.map(v => v.avgExecutionTimeMs)) %>, backgroundColor: '#fee75c', borderRadius: 4 }]
51
- },
52
- options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { title: { display: true, text: 'ms' } } } }
53
- });
54
- <% } %>
55
59
  </script>
@@ -10,13 +10,19 @@
10
10
  <div class="card mb-6">
11
11
  <div class="text-center py-4">
12
12
  <div class="text-4xl font-bold text-discord-primary"><%= totalMessages %></div>
13
- <div class="text-sm text-discord-foreground/60"><%= t('analytics.messages') %> (<%= daysBack %> <%= t('analytics.minutes') %>)</div>
13
+ <div class="text-sm text-discord-foreground/60"><%= t('analytics.messages') %> (<%= daysBack %> dias)</div>
14
14
  </div>
15
15
  </div>
16
- <div class="card">
16
+ <div class="card mb-6">
17
17
  <h3 class="text-lg font-semibold mb-4 text-discord-foreground"><%= t('analytics.messagesPerDay') %></h3>
18
18
  <canvas id="chartMessages" width="700" height="300"></canvas>
19
19
  </div>
20
+ <% if (typeof channelMessages !== 'undefined' && channelMessages.length > 0) { %>
21
+ <div class="card">
22
+ <h3 class="text-lg font-semibold mb-4 text-discord-foreground">Mensajes por Canal</h3>
23
+ <canvas id="chartChannels" width="700" height="250"></canvas>
24
+ </div>
25
+ <% } %>
20
26
  </div>
21
27
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
22
28
  <script>
@@ -28,4 +34,14 @@ new Chart(document.getElementById('chartMessages'), {
28
34
  },
29
35
  options: { responsive: true, plugins: { legend: { display: false } } }
30
36
  });
37
+ <% if (typeof channelMessages !== 'undefined' && channelMessages.length > 0) { %>
38
+ new Chart(document.getElementById('chartChannels'), {
39
+ type: 'bar',
40
+ data: {
41
+ labels: <%- JSON.stringify(channelMessages.map(v => v.channel_id)) %>,
42
+ datasets: [{ label: '<%= t("analytics.messages") %>', data: <%- JSON.stringify(channelMessages.map(v => v.message_count)) %>, backgroundColor: '#57f287', borderRadius: 4 }]
43
+ },
44
+ options: { responsive: true, plugins: { legend: { display: false } } }
45
+ });
46
+ <% } %>
31
47
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zumito-team/analytics-module",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Server analytics module with configurable tracking and optional panel integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,14 +13,19 @@
13
13
  "build": "tsc -p tsconfig.json && cp -r translations dist/ && cp -r views dist/"
14
14
  },
15
15
  "dependencies": {
16
+ "ejs": "^3.1.10",
17
+ "reflect-metadata": "^0.2.2"
18
+ },
19
+ "peerDependencies": {
16
20
  "@zumito-team/admin-module": "^1.8.0",
17
21
  "@zumito-team/user-panel-module": "^0.10.0",
18
- "ejs": "^3.1.10",
19
- "reflect-metadata": "^0.2.2",
20
- "zumito-framework": "^1.22.2"
22
+ "zumito-framework": "^1.24.0"
21
23
  },
22
24
  "devDependencies": {
23
25
  "@types/ejs": "^3.1.5",
24
- "typescript": "^5.9.3"
26
+ "@zumito-team/admin-module": "^1.8.0",
27
+ "@zumito-team/user-panel-module": "^0.10.0",
28
+ "typescript": "^5.9.3",
29
+ "zumito-framework": "^1.24.0"
25
30
  }
26
31
  }