'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: d / day(s), w / week(s), m / mo / month(s), * 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 };