Files
open-safetylog-ehs/server/routes/analytics.test.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

131 lines
6.6 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { buckets } from '../lib/analytics.ts'
import { localDate } from '../lib/dates.ts'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let manager: Awaited<ReturnType<typeof ctx.createUser>>
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
let north: { id: string }
let south: { id: string }
const daysAgo = (days: number, hour = 10) => {
const d = new Date()
d.setDate(d.getDate() - days)
d.setHours(hour, 0, 0, 0)
return d
}
const range = (fromDaysAgo: number, toDaysAgo = 0) => `from=${localDate(daysAgo(fromDaysAgo))}&to=${localDate(daysAgo(toDaysAgo))}`
async function report(type: string, when: Date, siteId = north.id, by = manager) {
const res = await by.post('/api/incidents', { title: `${type} report`, type, severity: 'low', occurredAt: when.toISOString(), siteId })
return res.body.incident.number as number
}
beforeAll(async () => {
ctx = await createTestContext()
north = await ctx.createSite('North')
south = await ctx.createSite('South')
manager = await ctx.createUser('safety_manager')
reporter = await ctx.createUser('reporter')
// Current 30-day period: 3 near misses/hazards, 1 injury, 1 spill
await report('near_miss', daysAgo(2, 14))
await report('near_miss', daysAgo(3, 14))
await report('hazard', daysAgo(5, 9), south.id)
const injury = await report('injury', daysAgo(6, 22))
await report('environmental', daysAgo(10))
// Previous period: 1 near miss, 2 injuries
await report('near_miss', daysAgo(40))
await report('injury', daysAgo(45))
await report('injury', daysAgo(50))
// Triage one report straight away
await manager.post(`/api/incidents/${injury}/transitions`, { to: 'triage' })
// A recordable case with hours worked
await manager.post(`/api/osha/incidents/${injury}/cases`, { name: 'Case', workRelated: true, newCase: true, daysAwayFromWork: true, daysAway: 2 })
const now = new Date()
await manager.put(`/api/osha/sites/${north.id}/hours`, { year: now.getFullYear(), months: [{ month: now.getMonth() + 1, hours: 10_000, averageEmployees: 60 }] })
})
afterAll(() => ctx.close())
describe('analytics', () => {
it('compares the period with the one before', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.kpis.reports).toEqual({ value: 5, previous: 3 })
expect(stats.kpis.leadingReports).toEqual({ value: 3, previous: 1 })
expect(stats.kpis.injuries).toEqual({ value: 1, previous: 2 })
expect(stats.kpis.nearMissRatio).toEqual({ value: 3, previous: 0.5 })
expect(stats.kpis.triagedWithin24h.value).toBeCloseTo(0.2)
expect(stats.kpis.recordables).toEqual({ value: 1, previous: 0 })
})
it('groups the trend, locations and the time-of-day heatmap', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.bucket).toBe('week')
const totals = stats.trend.reduce((sum: Record<string, number>, b: Record<string, number>) => ({ hazards: sum.hazards + b.hazards, injuries: sum.injuries + b.injuries, damage: sum.damage + b.damage }), { hazards: 0, injuries: 0, damage: 0 })
expect(totals).toEqual({ hazards: 3, injuries: 1, damage: 1 })
expect(stats.byLocation).toEqual([
{ id: north.id, name: 'North', count: 4 },
{ id: south.id, name: 'South', count: 1 },
])
expect(stats.heatmap).toHaveLength(7)
expect(stats.heatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBe(5)
expect(stats.heatmap[(daysAgo(6).getDay() + 6) % 7][22]).toBeGreaterThanOrEqual(1)
})
it('narrows to a site (showing its areas) and a type', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}&siteId=${north.id}&type=near_miss`)).body
expect(stats.kpis.reports.value).toBe(2)
expect(stats.byLocation).toEqual([{ id: null, name: 'Area not specified', count: 2 }])
})
it('computes rolling 12-month OSHA rates from hours worked', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.rates).toHaveLength(12)
expect(stats.rates.at(-1)).toEqual({ month: localDate(new Date()).slice(0, 7), trir: 20, dart: 20 })
// Summed over the years, so the test holds even when the injury falls in last year (early January)
expect(stats.yearly.reduce((sum: number, y: { recordables: number }) => sum + y.recordables, 0)).toBe(1)
expect(stats.yearly.at(-1)).toMatchObject({ year: new Date().getFullYear(), hours: 10_000 })
})
it('scopes reporters to their own reports and hides rates', async () => {
await report('hazard', daysAgo(1), north.id, reporter)
const stats = (await reporter.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.kpis.reports.value).toBe(1)
expect(stats.rates).toBeNull()
expect(stats.kpis.recordables).toBeNull()
})
it('validates the range', async () => {
expect((await manager.get(`/api/stats/analytics?from=2026-02-01&to=2026-01-01`)).status).toBe(400)
expect((await manager.get(`/api/stats/analytics?from=2015-01-01&to=2026-01-01`)).status).toBe(400)
})
it('uses weeks for short ranges and months for long ones', () => {
expect(buckets('2026-01-01', '2026-03-31').bucket).toBe('week')
const long = buckets('2025-01-15', '2025-12-31')
expect(long.bucket).toBe('month')
expect(long.starts).toHaveLength(12)
})
})
describe('saved views', () => {
it('keeps private views private and lets supervisors share', async () => {
const supervisor = await ctx.createUser('supervisor')
const mine = await reporter.post('/api/views', { page: 'analytics', name: 'My hazards', filters: { type: 'hazard', range: '90' } })
expect(mine.status).toBe(201)
expect((await reporter.post('/api/views', { page: 'analytics', name: 'Shared?', filters: {}, shared: true })).status).toBe(403)
await supervisor.post('/api/views', { page: 'analytics', name: 'Plant review', filters: { range: '365' }, shared: true })
const reporterViews = (await reporter.get('/api/views?page=analytics')).body.views.map((v: { name: string; mine: boolean }) => [v.name, v.mine])
expect(reporterViews).toEqual([
['My hazards', true],
['Plant review', false],
])
expect((await supervisor.get('/api/views?page=analytics')).body.views.map((v: { name: string }) => v.name)).toEqual(['Plant review'])
expect((await reporter.delete(`/api/views/${(await supervisor.get('/api/views?page=analytics')).body.views[0].id}`)).status).toBe(403)
expect((await reporter.delete(`/api/views/${mine.body.view.id}`)).status).toBe(204)
})
})