web,api: ticket stats reporting
vi did:web:vt3e.cat
Tue, 30 Jun 2026 22:59:13 +0100
4 files changed,
231 insertions(+),
4 deletions(-)
M
apps/api/src/app.ts
→
apps/api/src/app.ts
@@ -81,4 +81,7 @@ SanitisedTicketComment,
SanitisedTicketMessage, SanitisedTicketParticipant, SanitisedUser, + OverallStats, + LeaderboardEntry, + LeaderboardResponse, } from "./lib/schemas";
M
apps/api/src/lib/schemas.ts
→
apps/api/src/lib/schemas.ts
@@ -172,3 +172,21 @@ tickets: t.Array(TicketSchema),
count: t.Number(), }); export type TicketsResponse = Static<typeof TicketsResponseSchema>; + +export const OverallStatsSchema = t.Object({ + mean: t.Number(), + median: t.Number(), + totalClosed: t.Number(), +}); +export type OverallStats = Static<typeof OverallStatsSchema>; + +export const LeaderboardEntrySchema = t.Object({ + actor: t.Nullable(ActorSchema), + closedCount: t.Number(), + mean: t.Number(), + median: t.Number(), +}); +export type LeaderboardEntry = Static<typeof LeaderboardEntrySchema>; + +export const LeaderboardResponseSchema = t.Array(LeaderboardEntrySchema); +export type LeaderboardResponse = Static<typeof LeaderboardResponseSchema>;
M
apps/api/src/routes/guilds.ts
→
apps/api/src/routes/guilds.ts
@@ -1,7 +1,12 @@
-import { +import db, { + and, + eq, getActorByDiscordIdAndGuildDiscordId, getGuildByDiscordId, getTicketByGuild, + gte, + isNotNull, + tickets, } from "@stealth-developers/db"; import Elysia, { t } from "elysia";@@ -9,6 +14,7 @@ import { hasAccessToTicket } from "../lib/access";
import { authPlugin } from "../lib/auth"; import { getTicketsByGuildId } from "../lib/db"; import { + sanitiseActor, sanitiseTicket, sanitiseTicketAttachments, sanitiseTicketComments,@@ -18,6 +24,8 @@ } from "../lib/sanitise";
import { ErrorSchema, GuildAccessSchema, + LeaderboardResponseSchema, + OverallStatsSchema, TicketAttachmentSchema, TicketCommentSchema, TicketMessageSchema,@@ -66,6 +74,170 @@ 404: ErrorSchema,
}, tags: ["guilds"], }, + ) + .group("/stats", (app) => + app + .get( + "/overall", + async ({ query: { days }, guild }) => { + const cutoffDate = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : null; + + const overallConditions = [ + eq(tickets.guildId, guild.id), + eq(tickets.status, "closed"), + isNotNull(tickets.closedAt), + ]; + + if (cutoffDate) { + overallConditions.push(gte(tickets.closedAt, cutoffDate)); + } + + const closedTickets = await db + .select({ + openedAt: tickets.openedAt, + closedAt: tickets.closedAt, + }) + .from(tickets) + .where(and(...overallConditions)); + + const durations = closedTickets + .map((t) => { + const closedTime = t.closedAt ? t.closedAt.getTime() : null; + const openedTime = t.openedAt ? t.openedAt.getTime() : null; + if (closedTime === null || openedTime === null) return null; + return closedTime - openedTime; + }) + .filter((d): d is number => d !== null); + + const totalClosed = durations.length; + let mean = 0; + let median = 0; + + if (totalClosed > 0) { + const sum = durations.reduce((a, b) => a + b, 0); + mean = sum / totalClosed; + + const sorted = [...durations].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + const val1 = sorted[mid]; + const val2 = sorted[mid - 1]; + + if (sorted.length % 2 !== 0) { + median = val1 ?? 0; + } else { + median = val1 !== undefined && val2 !== undefined ? (val1 + val2) / 2 : 0; + } + } + + return { + mean, + median, + totalClosed, + }; + }, + { + query: t.Object({ + days: t.Optional(t.Numeric()), + }), + response: { + 200: OverallStatsSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + tags: ["guilds", "stats"], + }, + ) + .get( + "/leaderboard", + async ({ query: { days }, guild }) => { + const cutoffDate = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : null; + + const closedTicketsWithActors = await db.query.tickets.findMany({ + where: { + guildId: guild.id, + status: "closed", + closedBy: { isNotNull: true }, + closedAt: cutoffDate ? { gte: cutoffDate } : { isNotNull: true }, + }, + with: { + closedByActor: true, + }, + }); + + const modStats: Record< + number, + { + actor: any; + durations: number[]; + } + > = {}; + + for (const ticket of closedTicketsWithActors) { + const modId = ticket.closedBy; + if (modId === null) continue; + + if (!modStats[modId]) { + modStats[modId] = { + actor: ticket.closedByActor, + durations: [], + }; + } + + if (ticket.closedAt && ticket.openedAt) { + const duration = ticket.closedAt.getTime() - ticket.openedAt.getTime(); + modStats[modId].durations.push(duration); + } + } + + const leaderboard = Object.values(modStats).map(({ actor, durations }) => { + const closedCount = durations.length; + let mean = 0; + let median = 0; + + if (closedCount > 0) { + const sum = durations.reduce((a, b) => a + b, 0); + mean = sum / closedCount; + + const sorted = [...durations].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + const val1 = sorted[mid]; + const val2 = sorted[mid - 1]; + + if (sorted.length % 2 !== 0) { + median = val1 ?? 0; + } else { + median = val1 !== undefined && val2 !== undefined ? (val1 + val2) / 2 : 0; + } + } + + return { + actor: actor ? sanitiseActor(actor as any) : null, + closedCount, + mean, + median, + }; + }); + + leaderboard.sort((a, b) => b.closedCount - a.closedCount); + + return leaderboard; + }, + { + query: t.Object({ + days: t.Optional(t.Numeric()), + }), + response: { + 200: LeaderboardResponseSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + tags: ["guilds", "stats"], + }, + ), ) .group("/tickets", (app) => app
M
apps/web/src/views/GuildView.vue
→
apps/web/src/views/GuildView.vue
@@ -1,16 +1,50 @@
+<script setup lang="ts"> +import { onMounted, ref } from "vue"; +import { useRoute } from "vue-router"; + +import { client } from "@/stores/api"; + +const route = useRoute(); +const guildId = route.params.guildId as string | undefined; + +const stats = ref<{ mean: number; median: number; totalClosed: number }>({ + mean: 0, + median: 0, + totalClosed: 0, +}); + +onMounted(async () => { + if (!guildId) return; + + const res = await client.api.guilds({ guildId }).stats.overall.get(); + if (res.status === 200 && res.data) stats.value = res.data; +}); + +const formatMs = (ms: number) => { + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ${secs % 60}s`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ${mins % 60}m`; + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; +}; +</script> + <template> <div class="stats-row"> <div class="stat"> <span class="stat-label">Closed Tickets</span> - <span class="stat-value">2,000</span> + <span class="stat-value">{{ stats.totalClosed }}</span> </div> <div class="stat"> <span class="stat-label">Mean TTC</span> - <span class="stat-value">30m</span> + <span class="stat-value">{{ formatMs(stats.mean) }}</span> </div> <div class="stat"> <span class="stat-label">Median TTC</span> - <span class="stat-value">30m</span> + <span class="stat-value">{{ formatMs(stats.median) }}</span> </div> </div> </template>