import type { ClockEvent } from './clock'; import { missedClockIns, workDate } from './clock-rules'; /** * Workforce = everyone on the Plant roster (operators + mechanics), with the time * the Plant Clock recorded for them. Pure functions only, so the Employees page, * the Attendance page and each employee's dashboard read time the same way. */ /** "operators:abc…" ↔ "operators-abc…" — a URL segment cannot carry the colon cleanly. */ export const employeeSlug = (id:string) => id.replace(':','-'); export function employeeIdFromSlug(slug:string):string|null { const m=/^(operators|mechanics)-([a-z0-9]{15})$/.exec(slug); return m?`${m[1]}:${m[2]}`:null; } export type Session = { in:ClockEvent; out:ClockEvent|null; minutes:number; day:string }; export type DayTotal = { day:string; minutes:number; firstIn:string; lastOut:string|null; open:boolean }; export type AttendanceSummary = { status:'in'|'out'|'away'; last:ClockEvent|null; todayMinutes:number; weekMinutes:number; monthMinutes:number; daysThisMonth:number; missed:ClockEvent[]; days:DayTotal[]; sessions:Session[]; }; const byTime=(a:ClockEvent,b:ClockEvent)=>Date.parse(a.scanned_at)-Date.parse(b.scanned_at)||a.id.localeCompare(b.id); /** * Pair each IN with the same-day OUT that closed it. An IN still open TODAY counts * up to `now` (they are at work); an IN left open on an earlier day counts as 0 — * it is a missing clock-out, and inventing hours for it would overstate the time. */ export function sessionsOf(events:ClockEvent[], now:string):Session[] { const out:Session[]=[];let open:ClockEvent|null=null; const close=(s:ClockEvent,e:ClockEvent|null)=>{ const day=workDate(s.scanned_at); const minutes=e?Math.max(0,Math.floor((Date.parse(e.scanned_at)-Date.parse(s.scanned_at))/60_000)) :day===workDate(now)?Math.max(0,Math.floor((Date.parse(now)-Date.parse(s.scanned_at))/60_000)):0; out.push({in:s,out:e,minutes,day}); }; for(const e of [...events].sort(byTime)) { if(e.direction==='in'){if(open)close(open,null);open=e;} else if(open && workDate(open.scanned_at)===workDate(e.scanned_at)){close(open,e);open=null;} } if(open)close(open,null); return out; } /** Monday of the Africa/Johannesburg week containing `day` (YYYY-MM-DD). */ export function weekStart(day:string):string { const d=new Date(`${day}T12:00:00Z`);const dow=(d.getUTCDay()+6)%7; d.setUTCDate(d.getUTCDate()-dow);return d.toISOString().slice(0,10); } export function attendanceSummary(events:ClockEvent[], now:string):AttendanceSummary { const sorted=[...events].sort(byTime); const last=sorted.at(-1)??null; const today=workDate(now);const month=today.slice(0,7);const week=weekStart(today); const sessions=sessionsOf(sorted,now); const dayMap=new Map(); for(const s of sessions) { const d=dayMap.get(s.day)??{day:s.day,minutes:0,firstIn:s.in.scanned_at,lastOut:null,open:false}; d.minutes+=s.minutes; if(s.out)d.lastOut=s.out.scanned_at; d.open=!s.out; dayMap.set(s.day,d); } const days=[...dayMap.values()].sort((a,b)=>b.day.localeCompare(a.day)); const sum=(f:(d:DayTotal)=>boolean)=>days.filter(f).reduce((n,d)=>n+d.minutes,0); return { status:last && workDate(last.scanned_at)===today ? last.direction : 'away', last, todayMinutes:sum(d=>d.day===today), weekMinutes:sum(d=>d.day>=week && d.day<=today), monthMinutes:sum(d=>d.day.startsWith(month)), daysThisMonth:days.filter(d=>d.day.startsWith(month)).length, missed:missedClockIns(sorted,now), days,sessions, }; } export type Period = 'day'|'week'|'month'; const addDays=(day:string,n:number)=>{const d=new Date(`${day}T12:00:00Z`);d.setUTCDate(d.getUTCDate()+n);return d.toISOString().slice(0,10);}; /** Every calendar day (YYYY-MM-DD) in the period containing `anchor`, oldest first. */ export function periodDays(period:Period, anchor:string):string[] { if(period==='day')return [anchor]; if(period==='week'){const start=weekStart(anchor);return Array.from({length:7},(_,i)=>addDays(start,i));} const [y,m]=anchor.split('-').map(Number); const count=new Date(Date.UTC(y,m,0)).getUTCDate(); return Array.from({length:count},(_,i)=>`${anchor.slice(0,7)}-${String(i+1).padStart(2,'0')}`); } /** The same position one period earlier (-1) or later (+1). A month step lands on the 1st. */ export function shiftPeriod(period:Period, anchor:string, step:number):string { if(period==='day')return addDays(anchor,step); if(period==='week')return addDays(weekStart(anchor),7*step); const [y,m]=anchor.split('-').map(Number); const d=new Date(Date.UTC(y,m-1+step,1));return d.toISOString().slice(0,10); }