export interface MonthlyBucket { month: string // YYYY-MM label: string // e.g. "Jul" total: number leading: number // hazard + near_miss lagging: number // injury } const LEADING_TYPES = ['hazard', 'near_miss'] export function bucketIncidentsByMonth( incidents: Array<{ reported_at: string; incident_type: string }>, months = 12, now = new Date(), ): MonthlyBucket[] { const buckets: MonthlyBucket[] = [] const index = new Map() for (let i = months - 1; i >= 0; i--) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1)) const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}` const bucket: MonthlyBucket = { month, label: d.toLocaleString('en', { month: 'short', timeZone: 'UTC' }), total: 0, leading: 0, lagging: 0, } buckets.push(bucket) index.set(month, bucket) } for (const inc of incidents) { const month = inc.reported_at.slice(0, 7) const bucket = index.get(month) if (!bucket) continue bucket.total++ if (LEADING_TYPES.includes(inc.incident_type)) bucket.leading++ if (inc.incident_type === 'injury') bucket.lagging++ } return buckets } export interface RootCauseCount { cause: string count: number } // Root causes are free text (investigations.root_cause_summary); group on a // normalized form so trivially different phrasings still collapse together. export function topRootCauses( investigations: Array<{ root_cause_summary: string | null }>, top = 5, ): RootCauseCount[] { const counts = new Map() for (const inv of investigations) { const raw = (inv.root_cause_summary ?? '').trim() if (!raw) continue const key = raw.toLowerCase().replace(/\s+/g, ' ').replace(/[.。]$/, '') const entry = counts.get(key) if (entry) entry.count++ else counts.set(key, { cause: raw, count: 1 }) } return [...counts.values()] .sort((a, b) => b.count - a.count) .slice(0, top) }