import { and, eq, gte, inArray, isNotNull, lt, min } from 'drizzle-orm' import type { AnalyticsStats, Kpi, TypeGroup } from '../../shared/api-types.ts' import { hasRole, type IncidentType } from '../../shared/enums.ts' import { incidenceRate } from '../../shared/osha.ts' import type { SessionUser } from '../auth/sessions.ts' import type { Db } from '../db/client.ts' import { areas, auditLog, correctiveActions, hoursWorked, incidents, injuredPersons, investigations, rootCauses, sites } from '../db/schema.ts' import { addDays, localDate } from './dates.ts' import { visibleTo } from './incidents.ts' const DAY_MS = 86_400_000 export const typeGroup: Record = { near_miss: 'hazards', hazard: 'hazards', injury: 'injuries', illness: 'injuries', property_damage: 'damage', environmental: 'damage', } export interface AnalyticsFilters { /** Inclusive start date, `YYYY-MM-DD` (server time zone). */ from: string /** Inclusive end date. */ to: string siteId?: string type?: IncidentType } const startOf = (isoDate: string) => new Date(`${isoDate}T00:00:00`) const monthKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` const ratio = (part: number, whole: number) => (whole > 0 ? part / whole : null) const round1 = (value: number | null) => (value === null ? null : Math.round(value * 10) / 10) /** Monday of the week containing `date`, local time. */ function weekStart(date: Date): Date { const start = new Date(date.getFullYear(), date.getMonth(), date.getDate()) start.setDate(start.getDate() - ((start.getDay() + 6) % 7)) return start } /** Buckets covering [from, to]: weeks for ranges up to ~3 months, otherwise months. */ export function buckets(from: string, to: string): { bucket: 'week' | 'month'; starts: Date[] } { const start = startOf(from) const end = startOf(to) const days = (end.getTime() - start.getTime()) / DAY_MS + 1 const starts: Date[] = [] if (days <= 92) { for (let d = weekStart(start); d <= end; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7)) starts.push(d) return { bucket: 'week', starts } } for (let d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) starts.push(d) return { bucket: 'month', starts } } function bucketIndex(starts: Date[], date: Date): number { for (let i = starts.length - 1; i >= 0; i--) if (date >= starts[i]) return i return -1 } export async function computeAnalytics(db: Db, user: SessionUser, filters: AnalyticsFilters): Promise { const from = startOf(filters.from) const to = startOf(addDays(filters.to, 1)) // exclusive const length = to.getTime() - from.getTime() const previousFrom = new Date(from.getTime() - length) const visible = visibleTo(db, user) const scope = and(visible, filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, filters.type ? eq(incidents.type, filters.type) : undefined) const occurredIn = (start: Date, end: Date) => and(scope, gte(incidents.occurredAt, start), lt(incidents.occurredAt, end)) const columns = { id: incidents.id, type: incidents.type, occurredAt: incidents.occurredAt, createdAt: incidents.createdAt, siteId: incidents.siteId, areaId: incidents.areaId } const current = await db.select(columns).from(incidents).where(occurredIn(from, to)) const previous = await db.select(columns).from(incidents).where(occurredIn(previousFrom, from)) const countGroups = (rows: typeof current) => { const groups = { hazards: 0, injuries: 0, damage: 0 } for (const row of rows) groups[typeGroup[row.type]]++ return groups } const now = countGroups(current) const before = countGroups(previous) // Responsiveness: time from report to the first status change async function triagedShare(rows: typeof current): Promise { if (rows.length === 0) return null const firstMoves = await db .select({ id: auditLog.entityId, at: min(auditLog.createdAt) }) .from(auditLog) .where(and(eq(auditLog.entityType, 'incident'), eq(auditLog.action, 'status'), inArray(auditLog.entityId, rows.map((r) => r.id)))) .groupBy(auditLog.entityId) const moved = new Map(firstMoves.map((m) => [m.id, m.at])) const quick = rows.filter((r) => { const at = moved.get(r.id) return at && at.getTime() - r.createdAt.getTime() <= DAY_MS }) return quick.length / rows.length } async function averageDaysToClose(start: Date, end: Date) { const closed = await db .select({ createdAt: incidents.createdAt, closedAt: incidents.closedAt }) .from(incidents) .where(and(scope, isNotNull(incidents.closedAt), gte(incidents.closedAt, start), lt(incidents.closedAt, end))) if (closed.length === 0) return null return round1(closed.reduce((sum, r) => sum + (r.closedAt!.getTime() - r.createdAt.getTime()), 0) / closed.length / DAY_MS) } async function completedActions(start: Date, end: Date) { return db .select({ completedAt: correctiveActions.completedAt, dueDate: correctiveActions.dueDate }) .from(correctiveActions) .innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId)) .where(and(scope, isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, start), lt(correctiveActions.completedAt, end))) } const onTime = (row: { completedAt: Date | null; dueDate: string | null }) => localDate(row.completedAt!) <= row.dueDate! const actionsNow = await completedActions(from, to) const actionsBefore = await completedActions(previousFrom, from) const today = localDate() const overdueRows = await db .select({ id: correctiveActions.id }) .from(correctiveActions) .innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId)) .where(and(scope, inArray(correctiveActions.status, ['open', 'in_progress']), lt(correctiveActions.dueDate, today))) // Lagging: OSHA-recordable cases. Counts only, so supervisors may see them. const canSeeRates = hasRole(user.role, 'supervisor') async function recordableRows(start: Date, end: Date) { return db .select({ occurredAt: incidents.occurredAt, outcome: injuredPersons.outcome }) .from(injuredPersons) .innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId)) .where(and(eq(incidents.organizationId, user.organizationId), filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, eq(injuredPersons.recordable, true), gte(incidents.occurredAt, start), lt(incidents.occurredAt, end))) } const kpi = (value: number | null, previousValue: number | null): Kpi => ({ value, previous: previousValue }) const ratioOf = (groups: typeof now) => (groups.injuries ? Math.round(((groups.hazards / groups.injuries) * 10)) / 10 : null) // Trend const { bucket, starts } = buckets(filters.from, filters.to) const trend = starts.map((start) => ({ start: localDate(start), hazards: 0, injuries: 0, damage: 0 })) for (const row of current) { const i = bucketIndex(starts, row.occurredAt) if (i >= 0) trend[i][typeGroup[row.type]]++ } // Heatmap: weekday × hour of occurrence const heatmap = Array.from({ length: 7 }, () => Array(24).fill(0)) for (const row of current) heatmap[(row.occurredAt.getDay() + 6) % 7][row.occurredAt.getHours()]++ // Location: sites, or areas within the chosen site let byLocation: AnalyticsStats['byLocation'] if (filters.siteId) { const areaRows = await db.select({ id: areas.id, name: areas.name }).from(areas).where(eq(areas.siteId, filters.siteId)) const names = new Map(areaRows.map((a) => [a.id, a.name])) const counts = new Map() for (const row of current) counts.set(row.areaId, (counts.get(row.areaId) ?? 0) + 1) byLocation = [...counts].map(([id, count]) => ({ id, name: id ? (names.get(id) ?? 'Unknown area') : 'Area not specified', count })) } else { const siteRows = await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.organizationId, user.organizationId)) const names = new Map(siteRows.map((s) => [s.id, s.name])) const counts = new Map() for (const row of current) counts.set(row.siteId, (counts.get(row.siteId) ?? 0) + 1) byLocation = [...counts].map(([id, count]) => ({ id, name: names.get(id) ?? 'Unknown site', count })) } byLocation.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) const causeRows = current.length ? await db .select({ category: rootCauses.category }) .from(rootCauses) .innerJoin(investigations, eq(investigations.id, rootCauses.investigationId)) .where(inArray(investigations.incidentId, current.map((r) => r.id))) : [] const causeCounts = new Map() for (const row of causeRows) causeCounts.set(row.category, (causeCounts.get(row.category) ?? 0) + 1) const byRootCause = [...causeCounts].map(([category, count]) => ({ category: category as AnalyticsStats['byRootCause'][number]['category'], count })).sort((a, b) => b.count - a.count) // Actions completed per month, on time vs late const actionsByMonth: AnalyticsStats['actionsByMonth'] = [] for (let d = new Date(from.getFullYear(), from.getMonth(), 1); d < to; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) actionsByMonth.push({ month: monthKey(d), onTime: 0, late: 0 }) for (const row of actionsNow) { const entry = actionsByMonth.find((m) => m.month === monthKey(row.completedAt!)) if (entry) entry[onTime(row) ? 'onTime' : 'late']++ } // Rolling 12-month OSHA rates at each of the last 12 month ends let rates: AnalyticsStats['rates'] = null let yearly: AnalyticsStats['yearly'] = null let recordables: Kpi | null = null if (canSeeRates) { recordables = kpi((await recordableRows(from, to)).length, (await recordableRows(previousFrom, from)).length) const endMonth = new Date(startOf(filters.to).getFullYear(), startOf(filters.to).getMonth(), 1) const windowStart = new Date(endMonth.getFullYear() - 5, 0, 1) const cases = await recordableRows(windowStart, new Date(endMonth.getFullYear(), endMonth.getMonth() + 1, 1)) const hours = await db .select({ month: hoursWorked.month, hours: hoursWorked.hours }) .from(hoursWorked) .innerJoin(sites, eq(sites.id, hoursWorked.siteId)) .where(and(eq(sites.organizationId, user.organizationId), filters.siteId ? eq(hoursWorked.siteId, filters.siteId) : undefined, gte(hoursWorked.month, localDate(windowStart)))) const hoursByMonth = new Map() for (const h of hours) hoursByMonth.set(h.month.slice(0, 7), (hoursByMonth.get(h.month.slice(0, 7)) ?? 0) + Number(h.hours)) const casesIn = (start: Date, end: Date) => cases.filter((c) => c.occurredAt >= start && c.occurredAt < end) const hoursIn = (start: Date, end: Date) => { let total = 0 for (let d = new Date(start); d < end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) total += hoursByMonth.get(monthKey(d)) ?? 0 return total } const isDart = (c: { outcome: string | null }) => c.outcome === 'days_away' || c.outcome === 'restricted' rates = Array.from({ length: 12 }, (_, i) => { const monthStart = new Date(endMonth.getFullYear(), endMonth.getMonth() - 11 + i, 1) const windowEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1) const windowBegin = new Date(windowEnd.getFullYear() - 1, windowEnd.getMonth(), 1) const inWindow = casesIn(windowBegin, windowEnd) const h = hoursIn(windowBegin, windowEnd) return { month: monthKey(monthStart), trir: incidenceRate(inWindow.length, h), dart: incidenceRate(inWindow.filter(isDart).length, h) } }) yearly = Array.from({ length: 5 }, (_, i) => { const year = endMonth.getFullYear() - 4 + i const inYear = casesIn(new Date(year, 0, 1), new Date(year + 1, 0, 1)) const h = Math.round(hoursIn(new Date(year, 0, 1), new Date(year + 1, 0, 1))) const dartCases = inYear.filter(isDart).length return { year, recordables: inYear.length, dartCases, hours: h, trir: incidenceRate(inYear.length, h), dart: incidenceRate(dartCases, h) } }) } return { from: filters.from, to: filters.to, previousFrom: localDate(previousFrom), bucket, kpis: { reports: kpi(current.length, previous.length), leadingReports: kpi(now.hazards, before.hazards), injuries: kpi(now.injuries, before.injuries), nearMissRatio: kpi(ratioOf(now), ratioOf(before)), triagedWithin24h: kpi(await triagedShare(current), await triagedShare(previous)), actionsOnTime: kpi(ratio(actionsNow.filter(onTime).length, actionsNow.length), ratio(actionsBefore.filter(onTime).length, actionsBefore.length)), averageDaysToClose: kpi(await averageDaysToClose(from, to), await averageDaysToClose(previousFrom, from)), recordables, overdueActions: overdueRows.length, }, trend, byLocation, byRootCause, heatmap, actionsByMonth, rates, yearly, } }