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:
2026-06-05 02:02:48 +00:00
parent 0fcffe8d33
commit cdb5db0082
28 changed files with 3447 additions and 124 deletions

716
tests/statsShaping.test.js Normal file
View File

@@ -0,0 +1,716 @@
import { describe, it, expect } from 'vitest';
import { parsePeriod, shapeStats } from '../services/statsShaping.js';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// parsePeriod — presets (autocomplete suggestions)
// ---------------------------------------------------------------------------
describe('parsePeriod — presets', () => {
it('"7 days" → 7 days', () => {
const r = parsePeriod('7 days');
expect(r.value).toBe(7);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(7 * MS_PER_DAY);
expect(r.label).toBe('7 days');
});
it('"30 days" → 30 days', () => {
const r = parsePeriod('30 days');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(30 * MS_PER_DAY);
expect(r.label).toBe('30 days');
});
it('"3 months" → 3 × 30 days', () => {
const r = parsePeriod('3 months');
expect(r.value).toBe(3);
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(3 * 30 * MS_PER_DAY);
expect(r.label).toBe('3 months');
});
it('"6 months" → 6 × 30 days', () => {
const r = parsePeriod('6 months');
expect(r.durationMs).toBe(6 * 30 * MS_PER_DAY);
});
it('"1 year" → 365 days', () => {
const r = parsePeriod('1 year');
expect(r.value).toBe(1);
expect(r.unit).toBe('years');
expect(r.durationMs).toBe(365 * MS_PER_DAY);
expect(r.label).toBe('1 year');
});
});
// ---------------------------------------------------------------------------
// parsePeriod — day unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — day variants', () => {
it('<n>d', () => {
const r = parsePeriod('14d');
expect(r.value).toBe(14);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(14 * MS_PER_DAY);
});
it('<n>day (singular, no space)', () => {
const r = parsePeriod('1day');
expect(r.value).toBe(1);
expect(r.unit).toBe('days');
expect(r.label).toBe('1 day');
});
it('<n> day (singular, with space)', () => {
const r = parsePeriod('1 day');
expect(r.value).toBe(1);
expect(r.label).toBe('1 day');
});
it('<n> days', () => {
const r = parsePeriod('10 days');
expect(r.durationMs).toBe(10 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — week unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — week variants', () => {
it('<n>w', () => {
const r = parsePeriod('2w');
expect(r.value).toBe(2);
expect(r.unit).toBe('weeks');
expect(r.durationMs).toBe(2 * 7 * MS_PER_DAY);
expect(r.label).toBe('2 weeks');
});
it('<n> week (singular)', () => {
const r = parsePeriod('1 week');
expect(r.value).toBe(1);
expect(r.label).toBe('1 week');
});
it('<n>weeks (no space)', () => {
const r = parsePeriod('4weeks');
expect(r.value).toBe(4);
expect(r.unit).toBe('weeks');
});
it('<n> weeks', () => {
const r = parsePeriod('4 weeks');
expect(r.durationMs).toBe(4 * 7 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — month unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — month variants', () => {
it('<n>m', () => {
const r = parsePeriod('3m');
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(3 * 30 * MS_PER_DAY);
});
it('<n>mo', () => {
const r = parsePeriod('6mo');
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(6 * 30 * MS_PER_DAY);
});
it('<n> month (singular)', () => {
const r = parsePeriod('1 month');
expect(r.value).toBe(1);
expect(r.label).toBe('1 month');
});
it('<n> months', () => {
const r = parsePeriod('12 months');
expect(r.value).toBe(12);
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(12 * 30 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — year unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — year variants', () => {
it('<n>y', () => {
const r = parsePeriod('1y');
expect(r.unit).toBe('years');
expect(r.durationMs).toBe(365 * MS_PER_DAY);
});
it('<n> year (singular)', () => {
const r = parsePeriod('1 year');
expect(r.unit).toBe('years');
expect(r.label).toBe('1 year');
});
it('<n> years', () => {
const r = parsePeriod('2 years');
expect(r.value).toBe(2);
expect(r.label).toBe('2 years');
expect(r.durationMs).toBe(2 * 365 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — bare number = days
// ---------------------------------------------------------------------------
describe('parsePeriod — bare number defaults to days', () => {
it('"30" → 30 days', () => {
const r = parsePeriod('30');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(30 * MS_PER_DAY);
});
it('"7" → 7 days', () => {
const r = parsePeriod('7');
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(7 * MS_PER_DAY);
});
it('"365" → 365 days (not 1 year)', () => {
const r = parsePeriod('365');
expect(r.unit).toBe('days');
expect(r.value).toBe(365);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — case & whitespace tolerance
// ---------------------------------------------------------------------------
describe('parsePeriod — case and whitespace tolerance', () => {
it('uppercase "7 DAYS"', () => {
const r = parsePeriod('7 DAYS');
expect(r.value).toBe(7);
expect(r.unit).toBe('days');
});
it('mixed case "3 Months"', () => {
const r = parsePeriod('3 Months');
expect(r.unit).toBe('months');
});
it('leading and trailing whitespace " 30 days "', () => {
const r = parsePeriod(' 30 days ');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
});
it('multiple internal spaces "7 days"', () => {
const r = parsePeriod('7 days');
expect(r.value).toBe(7);
expect(r.unit).toBe('days');
});
it('no space between number and unit "30days"', () => {
const r = parsePeriod('30days');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
});
it('"1YEAR" (uppercase, no space)', () => {
const r = parsePeriod('1YEAR');
expect(r.unit).toBe('years');
});
});
// ---------------------------------------------------------------------------
// parsePeriod — unparseable → 30-day default
// ---------------------------------------------------------------------------
describe('parsePeriod — unparseable inputs → 30-day default', () => {
const expectDefault = r => {
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(30 * MS_PER_DAY);
expect(r.label).toBe('30 days');
};
it('null → default', () => expectDefault(parsePeriod(null)));
it('undefined → default', () => expectDefault(parsePeriod(undefined)));
it('empty string → default', () => expectDefault(parsePeriod('')));
it('whitespace only → default', () => expectDefault(parsePeriod(' ')));
it('letters only → default', () => expectDefault(parsePeriod('abc')));
it('natural language → default', () => expectDefault(parsePeriod('last month')));
it('unknown unit "5x" → default', () => expectDefault(parsePeriod('5x')));
it('"0" → default (zero is nonsensical)', () => expectDefault(parsePeriod('0')));
it('"0d" → default', () => expectDefault(parsePeriod('0d')));
it('negative-like "-5d" → default (not a digit-start)', () => expectDefault(parsePeriod('-5d')));
});
// ---------------------------------------------------------------------------
// parsePeriod — return shape invariant
// ---------------------------------------------------------------------------
describe('parsePeriod — return shape', () => {
it('always returns { durationMs, value, unit, label }', () => {
for (const input of ['7d', '2w', '3m', '6mo', '1y', '30', null, 'junk']) {
const r = parsePeriod(input);
expect(typeof r.durationMs).toBe('number');
expect(typeof r.value).toBe('number');
expect(typeof r.unit).toBe('string');
expect(typeof r.label).toBe('string');
}
});
it('returns a fresh object each call (not the same frozen reference)', () => {
const a = parsePeriod(null);
const b = parsePeriod(undefined);
expect(a).not.toBe(b);
});
});
// ===========================================================================
// shapeStats — fixtures
// ===========================================================================
const MEMBER = 'member-001';
const OTHER = 'other-002';
function event(overrides) {
return {
staffId: OTHER,
type: 'claim',
tier: 0,
ticketType: 'email',
wasClaimed: null,
resolverId: null,
fromId: null,
toId: null,
...overrides
};
}
// ---------------------------------------------------------------------------
// shapeStats — claims
// ---------------------------------------------------------------------------
describe('shapeStats — claims', () => {
it('counts claim events where staffId===member', () => {
const events = [
event({ type: 'claim', staffId: MEMBER }),
event({ type: 'claim', staffId: MEMBER }),
event({ type: 'claim', staffId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').claims).toBe(2);
});
it('does not count claims by other staff', () => {
expect(shapeStats([event({ type: 'claim', staffId: OTHER })], MEMBER, 'all').claims).toBe(0);
});
it('non-claim event types do not increment claims', () => {
const events = [
event({ type: 'close', staffId: MEMBER }),
event({ type: 'escalate', staffId: MEMBER }),
];
expect(shapeStats(events, MEMBER, 'all').claims).toBe(0);
});
it('claimsWhileEscalated groups by numeric tier for tier > 0', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, tier: 0 }),
event({ type: 'claim', staffId: MEMBER, tier: 1 }),
event({ type: 'claim', staffId: MEMBER, tier: 1 }),
event({ type: 'claim', staffId: MEMBER, tier: 2 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims).toBe(4);
expect(r.claimsWhileEscalated).toEqual({ 1: 2, 2: 1 });
});
it('tier=0 claims are NOT included in claimsWhileEscalated', () => {
const events = [event({ type: 'claim', staffId: MEMBER, tier: 0 })];
expect(shapeStats(events, MEMBER, 'all').claimsWhileEscalated).toEqual({});
});
it('claimsWhileEscalated only includes the member\'s own claims', () => {
const events = [event({ type: 'claim', staffId: OTHER, tier: 1 })];
expect(shapeStats(events, MEMBER, 'all').claimsWhileEscalated).toEqual({});
});
});
// ---------------------------------------------------------------------------
// shapeStats — closes
// ---------------------------------------------------------------------------
describe('shapeStats — closes', () => {
it('counts close events where staffId===member', () => {
const events = [
event({ type: 'close', staffId: MEMBER }),
event({ type: 'close', staffId: MEMBER }),
event({ type: 'close', staffId: OTHER }),
event({ type: 'close', staffId: 'system' }),
];
expect(shapeStats(events, MEMBER, 'all').closes).toBe(2);
});
it('system closes do not count toward member closes', () => {
const events = [event({ type: 'close', staffId: 'system', resolverId: MEMBER })];
expect(shapeStats(events, MEMBER, 'all').closes).toBe(0);
});
it('unclaimedAtClose counts member closes where wasClaimed===false', () => {
const events = [
event({ type: 'close', staffId: MEMBER, wasClaimed: false }),
event({ type: 'close', staffId: MEMBER, wasClaimed: true }),
event({ type: 'close', staffId: MEMBER, wasClaimed: false }),
event({ type: 'close', staffId: OTHER, wasClaimed: false }),
];
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(2);
});
it('wasClaimed===true does NOT count as unclaimed-at-close', () => {
const events = [event({ type: 'close', staffId: MEMBER, wasClaimed: true })];
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(0);
});
it('wasClaimed===null does NOT count as unclaimed-at-close', () => {
const events = [event({ type: 'close', staffId: MEMBER, wasClaimed: null })];
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(0);
});
});
// ---------------------------------------------------------------------------
// shapeStats — resolved (credit to claimer via resolverId)
// ---------------------------------------------------------------------------
describe('shapeStats — resolved', () => {
it('counts close events where resolverId===member', () => {
const events = [
event({ type: 'close', staffId: OTHER, resolverId: MEMBER }),
event({ type: 'close', staffId: OTHER, resolverId: OTHER }),
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.resolved).toBe(2);
expect(r.closes).toBe(1);
});
it('resolved is distinct from closes — different field keys', () => {
const events = [
event({ type: 'close', staffId: OTHER, resolverId: MEMBER }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.resolved).toBe(1);
expect(r.closes).toBe(0);
});
it('a self-close-and-resolve increments both closes and resolved', () => {
const events = [
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true })
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.closes).toBe(1);
expect(r.resolved).toBe(1);
});
});
// ---------------------------------------------------------------------------
// shapeStats — escalations / de-escalations
// ---------------------------------------------------------------------------
describe('shapeStats — escalations', () => {
it('groups escalate events by numeric tier', () => {
const events = [
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
event({ type: 'escalate', staffId: MEMBER, tier: 2 }),
];
expect(shapeStats(events, MEMBER, 'all').escalations).toEqual({ 1: 2, 2: 1 });
});
it('ignores escalations by other staff', () => {
expect(shapeStats([event({ type: 'escalate', staffId: OTHER, tier: 1 })], MEMBER, 'all').escalations).toEqual({});
});
it('escalations is empty when no escalate events', () => {
expect(shapeStats([event({ type: 'claim', staffId: MEMBER })], MEMBER, 'all').escalations).toEqual({});
});
});
describe('shapeStats — de-escalations', () => {
it('groups deescalate events by numeric tier', () => {
const events = [
event({ type: 'deescalate', staffId: MEMBER, tier: 1 }),
event({ type: 'deescalate', staffId: MEMBER, tier: 2 }),
event({ type: 'deescalate', staffId: MEMBER, tier: 2 }),
];
expect(shapeStats(events, MEMBER, 'all').deescalations).toEqual({ 1: 1, 2: 2 });
});
it('ignores deescalations by other staff', () => {
expect(shapeStats([event({ type: 'deescalate', staffId: OTHER, tier: 1 })], MEMBER, 'all').deescalations).toEqual({});
});
it('escalations and deescalations are counted independently', () => {
const events = [
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
event({ type: 'deescalate', staffId: MEMBER, tier: 1 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.escalations).toEqual({ 1: 1 });
expect(r.deescalations).toEqual({ 1: 1 });
});
});
// ---------------------------------------------------------------------------
// shapeStats — transfers in vs out
// ---------------------------------------------------------------------------
describe('shapeStats — transfers', () => {
it('transfersIn counts transfer events where toId===member', () => {
const events = [
event({ type: 'transfer', staffId: OTHER, toId: MEMBER }),
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').transfersIn).toBe(1);
});
it('transfersOut counts transfer events where staffId===member (initiator)', () => {
const events = [
event({ type: 'transfer', staffId: MEMBER, toId: OTHER }),
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').transfersOut).toBe(1);
});
it('single transfer counts out for sender, in for receiver', () => {
const events = [event({ type: 'transfer', staffId: MEMBER, toId: OTHER })];
const rMember = shapeStats(events, MEMBER, 'all');
const rOther = shapeStats(events, OTHER, 'all');
expect(rMember.transfersOut).toBe(1);
expect(rMember.transfersIn).toBe(0);
expect(rOther.transfersIn).toBe(1);
expect(rOther.transfersOut).toBe(0);
});
it('transfersIn and transfersOut are counted on a single event if member is both', () => {
// Degenerate: staffId===toId===member. Phase 5b prevents this in practice,
// but the shaper is pure and should still count both dimensions.
const events = [event({ type: 'transfer', staffId: MEMBER, toId: MEMBER })];
const r = shapeStats(events, MEMBER, 'all');
expect(r.transfersOut).toBe(1);
expect(r.transfersIn).toBe(1);
});
});
// ---------------------------------------------------------------------------
// shapeStats — reopens (via resolverId, not staffId)
// ---------------------------------------------------------------------------
describe('shapeStats — reopens', () => {
it('counts reopen events where resolverId===member', () => {
const events = [
event({ type: 'reopen', staffId: 'system', resolverId: MEMBER }),
event({ type: 'reopen', staffId: 'system', resolverId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(1);
});
it('staffId on reopen is typically "system" — does not drive the reopen count', () => {
const events = [event({ type: 'reopen', staffId: MEMBER, resolverId: OTHER })];
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(0);
});
it('null resolverId does not count', () => {
const events = [event({ type: 'reopen', staffId: 'system', resolverId: null })];
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(0);
});
});
// ---------------------------------------------------------------------------
// shapeStats — source filter
// ---------------------------------------------------------------------------
describe('shapeStats — source filter', () => {
it('"all" includes both email and discord events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER, 'all').claims).toBe(2);
});
it('"email" includes only email events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER, 'email').claims).toBe(1);
});
it('"discord" includes only discord events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER, 'discord').claims).toBe(1);
});
it('source filter applies before all metric calculations', () => {
const events = [
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: false, ticketType: 'email' }),
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'email');
expect(r.closes).toBe(1);
expect(r.resolved).toBe(1);
expect(r.unclaimedAtClose).toBe(1);
});
it('undefined source defaults to "all"', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER).claims).toBe(2);
});
});
// ---------------------------------------------------------------------------
// shapeStats — bySource breakdown
// ---------------------------------------------------------------------------
describe('shapeStats — bySource breakdown', () => {
it('splits claims by email/discord', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.claims).toBe(1);
expect(r.bySource.discord.claims).toBe(2);
});
it('splits closes by email/discord', () => {
const events = [
event({ type: 'close', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'close', staffId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.closes).toBe(1);
expect(r.bySource.discord.closes).toBe(1);
});
it('splits resolved by email/discord (using resolverId key)', () => {
const events = [
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'email' }),
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.resolved).toBe(1);
expect(r.bySource.discord.resolved).toBe(1);
});
it('events with unknown ticketType are bucketed as email', () => {
const events = [event({ type: 'claim', staffId: MEMBER, ticketType: undefined })];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.claims).toBe(1);
expect(r.bySource.discord.claims).toBe(0);
});
it('bySource totals match headline counts', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true, ticketType: 'email' }),
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.claims + r.bySource.discord.claims).toBe(r.claims);
expect(r.bySource.email.closes + r.bySource.discord.closes).toBe(r.closes);
expect(r.bySource.email.resolved + r.bySource.discord.resolved).toBe(r.resolved);
});
});
// ---------------------------------------------------------------------------
// shapeStats — edge cases
// ---------------------------------------------------------------------------
describe('shapeStats — edge cases', () => {
it('empty events array returns zero counts', () => {
const r = shapeStats([], MEMBER, 'all');
expect(r.claims).toBe(0);
expect(r.closes).toBe(0);
expect(r.resolved).toBe(0);
expect(r.unclaimedAtClose).toBe(0);
expect(r.transfersIn).toBe(0);
expect(r.transfersOut).toBe(0);
expect(r.reopens).toBe(0);
expect(r.claimsWhileEscalated).toEqual({});
expect(r.escalations).toEqual({});
expect(r.deescalations).toEqual({});
expect(r.bySource.email.claims).toBe(0);
expect(r.bySource.discord.claims).toBe(0);
});
it('null events array is treated as empty', () => {
const r = shapeStats(null, MEMBER, 'all');
expect(r.claims).toBe(0);
});
it('events from other members are ignored for the requested member', () => {
const events = [
event({ type: 'claim', staffId: OTHER }),
event({ type: 'close', staffId: OTHER, resolverId: OTHER }),
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
event({ type: 'reopen', staffId: 'system', resolverId: OTHER }),
event({ type: 'escalate', staffId: OTHER, tier: 1 }),
event({ type: 'deescalate',staffId: OTHER, tier: 1 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims).toBe(0);
expect(r.closes).toBe(0);
expect(r.resolved).toBe(0);
expect(r.transfersIn).toBe(0);
expect(r.transfersOut).toBe(0);
expect(r.reopens).toBe(0);
expect(r.escalations).toEqual({});
expect(r.deescalations).toEqual({});
});
it('handles member appearing in multiple roles across events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, tier: 0, ticketType: 'email' }),
event({ type: 'close', staffId: MEMBER, resolverId: OTHER, wasClaimed: false, ticketType: 'email' }),
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, wasClaimed: true, ticketType: 'discord' }),
event({ type: 'transfer', staffId: OTHER, toId: MEMBER }),
event({ type: 'reopen', staffId: 'system', resolverId: MEMBER }),
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims).toBe(1);
expect(r.closes).toBe(1);
expect(r.unclaimedAtClose).toBe(1);
expect(r.resolved).toBe(1);
expect(r.transfersIn).toBe(1);
expect(r.transfersOut).toBe(0);
expect(r.reopens).toBe(1);
expect(r.escalations).toEqual({ 1: 1 });
});
it('events matching no member fields contribute nothing', () => {
const events = [
event({ type: 'response', staffId: MEMBER }), // 'response' type has no shaper rule
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims + r.closes + r.resolved + r.transfersIn + r.transfersOut + r.reopens).toBe(0);
});
});