Files
open-safetylog-ehs/server/lib/analytics.ts
T
jayjennings 61c876edec
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
Add OSHA Form 301 and Osha page components
- Implemented Form301 component for displaying OSHA Form 301 equivalent, including employee and case information.
- Created Osha component to manage OSHA recordkeeping, including tabs for 300 Log, 300A Summary, Hours & rates, and Submit to OSHA.
- Added functionality for printing and downloading CSV files for OSHA submissions.
- Integrated loading and error handling states for data fetching.
2026-10-06 13:09:57 -07:00

253 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<IncidentType, TypeGroup> = {
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<AnalyticsStats> {
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<number | null> {
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<number>(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<string | null, number>()
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<string, number>()
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<string, number>()
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<string, number>()
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,
}
}