113 lines
3.5 KiB
TypeScript
113 lines
3.5 KiB
TypeScript
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<WorkSessionAnomaly>();
|
|
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;
|
|
}
|