export type AttendanceEventType = 'check_in' | 'check_out'; export interface AttendanceEventInput { id: string; type: AttendanceEventType; occurredAt: string; } export type WorkSessionAnomaly = | 'auto_closed' | 'missing_check_in' | 'missing_check_out' | 'repeated_check_in'; export interface ReconcileWorkSessionOptions { now: string; /** A still-open shift becomes anomalous after this many minutes. */ openShiftThresholdMinutes: number; /** Optional scheduler-provided cutoff. It is applied only after the cutoff has actually passed. */ automaticCloseAt?: string; } export interface WorkSessionCandidate { startedAt: string | null; endedAt: string | null; workedMinutes: number; status: 'open' | 'pending_review'; anomalies: WorkSessionAnomaly[]; attendanceEventIds: string[]; } interface ParsedEvent extends AttendanceEventInput { timestamp: number; } export function reconcileWorkSession( events: readonly AttendanceEventInput[], options: ReconcileWorkSessionOptions, ): WorkSessionCandidate | null { if (!Number.isInteger(options.openShiftThresholdMinutes) || options.openShiftThresholdMinutes < 1) { throw new Error('openShiftThresholdMinutes must be a positive integer'); } if (events.length === 0) return null; const now = parseInstant(options.now, 'now'); const automaticCloseAt = options.automaticCloseAt ? parseInstant(options.automaticCloseAt, 'automaticCloseAt') : undefined; const parsed = events.map(parseEvent).sort((left, right) => left.timestamp - right.timestamp || left.id.localeCompare(right.id)); const anomalies = new Set(); const attendanceEventIds: string[] = []; let openAt: number | null = null; let firstStart: number | null = null; let lastEnd: number | null = null; let workedMilliseconds = 0; for (const event of parsed) { attendanceEventIds.push(event.id); if (event.type === 'check_in') { if (openAt !== null) { anomalies.add('repeated_check_in'); continue; } openAt = event.timestamp; firstStart ??= event.timestamp; continue; } if (openAt === null) { anomalies.add('missing_check_in'); lastEnd = Math.max(lastEnd ?? event.timestamp, event.timestamp); continue; } workedMilliseconds += Math.max(0, event.timestamp - openAt); lastEnd = event.timestamp; openAt = null; } if (openAt !== null) { const canAutoClose = automaticCloseAt !== undefined && automaticCloseAt > openAt && automaticCloseAt <= now; if (canAutoClose) { workedMilliseconds += automaticCloseAt - openAt; lastEnd = automaticCloseAt; openAt = null; anomalies.add('auto_closed'); } else if (now - openAt >= options.openShiftThresholdMinutes * 60_000) { anomalies.add('missing_check_out'); } } return { startedAt: firstStart === null ? null : new Date(firstStart).toISOString(), endedAt: openAt === null && lastEnd !== null ? new Date(lastEnd).toISOString() : null, workedMinutes: Math.floor(workedMilliseconds / 60_000), status: openAt !== null && anomalies.size === 0 ? 'open' : 'pending_review', anomalies: [...anomalies].sort(), attendanceEventIds, }; } function parseEvent(event: AttendanceEventInput): ParsedEvent { return { ...event, timestamp: parseInstant(event.occurredAt, `event ${event.id}`) }; } function parseInstant(value: string, label: string): number { const timestamp = new Date(value).valueOf(); if (!Number.isFinite(timestamp)) throw new Error(`${label} must be an ISO date-time`); return timestamp; }