Add per-staff metrics: StaffAction event log + /stats command
Event-sourced tracking of staff/ticket lifecycle actions, plus a /stats command. Foundation for a future tickets-website analytics dashboard. Data: - StaffAction model (event log) + Ticket.game / Ticket.closedAt - STATS_ADMIN_IDS config (who may view others' stats) Recording (fire-and-forget, idempotent on real state transitions): - claim, response (channel reply + /response send), escalate, de-escalate, transfer, close (4 sites), reopen — each denormalizes ticketType, tier, priority, game, requester (senderEmail / creatorId), guildId - close events carry closerType / resolverId (claimer credit) / wasClaimed; transfer carries fromId / toId; reopen stamps resolverId - conditional close transition helper (atomic open->closed + closedAt) shared by all four close paths Query + command: - pure period parser (presets + free-text) and stats shaper (per-metric keys) - command-aware autocomplete dispatch - /stats: period (autocomplete) + member (admin-gated) + source (all/email/ discord), ManageMessages + staff-role gated, ephemeral, tier-labeled embed 288+ unit tests; timing/busiest-times data is collected but displayed later.
This commit is contained in:
154
services/statsShaping.js
Normal file
154
services/statsShaping.js
Normal file
@@ -0,0 +1,154 @@
|
||||
'use strict';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
// Months = 30 days, years = 365 days — fixed-day approximation for windowing only.
|
||||
const MS = {
|
||||
days: MS_PER_DAY,
|
||||
weeks: 7 * MS_PER_DAY,
|
||||
months: 30 * MS_PER_DAY,
|
||||
years: 365 * MS_PER_DAY
|
||||
};
|
||||
|
||||
const DEFAULT_PERIOD = Object.freeze({ durationMs: 30 * MS_PER_DAY, value: 30, unit: 'days', label: '30 days' });
|
||||
|
||||
/**
|
||||
* parsePeriod(input) → { durationMs, value, unit, label }
|
||||
*
|
||||
* Accepts the autocomplete presets ("7 days", "30 days", "3 months", "6 months",
|
||||
* "1 year") and free text: <n>d / day(s), <n>w / week(s), <n>m / mo / month(s),
|
||||
* <n>y / year(s), or a bare integer (= days). Case- and whitespace-tolerant.
|
||||
* Unparseable or zero input returns the 30-day default.
|
||||
*
|
||||
* The caller computes the cutoff as: cutoff = Date.now() - durationMs
|
||||
* Month = 30 days, year = 365 days (windowing approximation, not calendar-accurate).
|
||||
*/
|
||||
function parsePeriod(input) {
|
||||
if (input == null) return Object.assign({}, DEFAULT_PERIOD);
|
||||
const s = String(input).trim().toLowerCase();
|
||||
if (!s) return Object.assign({}, DEFAULT_PERIOD);
|
||||
|
||||
const match = s.match(/^(\d+)\s*(d|day|days|w|week|weeks|m|mo|month|months|y|year|years)?$/);
|
||||
if (!match) return Object.assign({}, DEFAULT_PERIOD);
|
||||
|
||||
const n = parseInt(match[1], 10);
|
||||
if (!n) return Object.assign({}, DEFAULT_PERIOD);
|
||||
|
||||
const unitStr = match[2];
|
||||
let unit, durationMs;
|
||||
|
||||
if (!unitStr || unitStr === 'd' || unitStr === 'day' || unitStr === 'days') {
|
||||
unit = 'days';
|
||||
durationMs = n * MS.days;
|
||||
} else if (unitStr === 'w' || unitStr === 'week' || unitStr === 'weeks') {
|
||||
unit = 'weeks';
|
||||
durationMs = n * MS.weeks;
|
||||
} else if (unitStr === 'm' || unitStr === 'mo' || unitStr === 'month' || unitStr === 'months') {
|
||||
unit = 'months';
|
||||
durationMs = n * MS.months;
|
||||
} else if (unitStr === 'y' || unitStr === 'year' || unitStr === 'years') {
|
||||
unit = 'years';
|
||||
durationMs = n * MS.years;
|
||||
} else {
|
||||
return Object.assign({}, DEFAULT_PERIOD);
|
||||
}
|
||||
|
||||
const singular = unit.slice(0, -1);
|
||||
const label = n === 1 ? `1 ${singular}` : `${n} ${unit}`;
|
||||
return { durationMs, value: n, unit, label };
|
||||
}
|
||||
|
||||
/**
|
||||
* shapeStats(events, memberId, source) → counts object
|
||||
*
|
||||
* Pure aggregator over an array of StaffAction-shaped objects.
|
||||
* source: 'all' | 'email' | 'discord' (default: 'all')
|
||||
*
|
||||
* Field keying:
|
||||
* claims type 'claim', staffId === member
|
||||
* claimsWhileEscalated above, tier > 0, grouped by numeric tier key
|
||||
* closes type 'close', staffId === member
|
||||
* resolved type 'close', resolverId === member (claimer credit)
|
||||
* unclaimedAtClose type 'close', staffId === member, wasClaimed === false
|
||||
* escalations type 'escalate', staffId === member, grouped by tier
|
||||
* deescalations type 'deescalate', staffId === member, grouped by tier
|
||||
* transfersIn type 'transfer', toId === member
|
||||
* transfersOut type 'transfer', staffId === member (initiator)
|
||||
* reopens type 'reopen', resolverId === member
|
||||
*
|
||||
* Tier labels (tier 1 → "Tier 2", tier 2 → "Tier 3") are NOT applied here;
|
||||
* Phase 10 maps numeric tier keys to display labels.
|
||||
*
|
||||
* bySource breaks headline counts (claims, closes, resolved) by ticketType.
|
||||
*/
|
||||
function shapeStats(events, memberId, source) {
|
||||
const src = source || 'all';
|
||||
const pool = src === 'all'
|
||||
? (events || [])
|
||||
: (events || []).filter(e => e.ticketType === src);
|
||||
|
||||
const result = {
|
||||
claims: 0,
|
||||
claimsWhileEscalated: {},
|
||||
closes: 0,
|
||||
resolved: 0,
|
||||
unclaimedAtClose: 0,
|
||||
escalations: {},
|
||||
deescalations: {},
|
||||
transfersIn: 0,
|
||||
transfersOut: 0,
|
||||
reopens: 0,
|
||||
bySource: {
|
||||
email: { claims: 0, closes: 0, resolved: 0 },
|
||||
discord: { claims: 0, closes: 0, resolved: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
for (const e of pool) {
|
||||
const tt = e.ticketType === 'discord' ? 'discord' : 'email';
|
||||
|
||||
if (e.type === 'claim' && e.staffId === memberId) {
|
||||
result.claims++;
|
||||
result.bySource[tt].claims++;
|
||||
if (e.tier > 0) {
|
||||
result.claimsWhileEscalated[e.tier] = (result.claimsWhileEscalated[e.tier] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.type === 'close' && e.staffId === memberId) {
|
||||
result.closes++;
|
||||
result.bySource[tt].closes++;
|
||||
if (e.wasClaimed === false) {
|
||||
result.unclaimedAtClose++;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.type === 'close' && e.resolverId === memberId) {
|
||||
result.resolved++;
|
||||
result.bySource[tt].resolved++;
|
||||
}
|
||||
|
||||
if (e.type === 'escalate' && e.staffId === memberId) {
|
||||
result.escalations[e.tier] = (result.escalations[e.tier] || 0) + 1;
|
||||
}
|
||||
|
||||
if (e.type === 'deescalate' && e.staffId === memberId) {
|
||||
result.deescalations[e.tier] = (result.deescalations[e.tier] || 0) + 1;
|
||||
}
|
||||
|
||||
if (e.type === 'transfer' && e.toId === memberId) {
|
||||
result.transfersIn++;
|
||||
}
|
||||
|
||||
if (e.type === 'transfer' && e.staffId === memberId) {
|
||||
result.transfersOut++;
|
||||
}
|
||||
|
||||
if (e.type === 'reopen' && e.resolverId === memberId) {
|
||||
result.reopens++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { parsePeriod, shapeStats };
|
||||
Reference in New Issue
Block a user