mega-plant/lib/workforce.ts

98 lines
4.6 KiB
TypeScript
Raw Permalink Normal View History

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<string,DayTotal>();
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);
}