/** Mirrors PleasePayMe.Application.Jobs.PaySchedule for calendar rendering. */ export type WeekendPolicy = "before_weekend" | "after_weekend"; export type PayOccurrence = { date: string; jobId: number; jobName: string; scheduledDay: number; percent: number; amount: number; }; function pad2(n: number): string { return String(n).padStart(2, "0"); } export function toIso(year: number, month: number, day: number): string { return `${year}-${pad2(month)}-${pad2(day)}`; } export function parseIso(iso: string): { year: number; month: number; day: number } { const [year, month, day] = iso.slice(0, 10).split("-").map(Number); return { year, month, day }; } export function addDaysIso(iso: string, delta: number): string { const { year, month, day } = parseIso(iso); const dt = new Date(Date.UTC(year, month - 1, day + delta)); return toIso(dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate()); } export function weekdayMon0(iso: string): number { const { year, month, day } = parseIso(iso); // 0 = Mon … 6 = Sun const js = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); return (js + 6) % 7; } export function adjustForWeekend(iso: string, policy: WeekendPolicy): string { const wd = weekdayMon0(iso); // Mon=0 … Sat=5, Sun=6 if (wd === 5) { return policy === "before_weekend" ? addDaysIso(iso, -1) : addDaysIso(iso, 2); } if (wd === 6) { return policy === "before_weekend" ? addDaysIso(iso, -2) : addDaysIso(iso, 1); } return iso; } export function nominalDate(year: number, month: number, dayOfMonth: number): string { const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); const actualDay = Math.min(dayOfMonth, daysInMonth); return toIso(year, month, actualDay); } export function paysInRange( jobs: Array<{ id: number; name: string; salary_amount: number; pay_days: number[]; first_pay_percent: number; weekend_policy: WeekendPolicy; is_active: boolean; }>, rangeStart: string, rangeEnd: string, ): PayOccurrence[] { const start = parseIso(rangeStart); const end = parseIso(rangeEnd); const result: PayOccurrence[] = []; for (const job of jobs) { if (!job.is_active || job.pay_days.length === 0) continue; const ordered = [...job.pay_days].sort((a, b) => a - b).slice(0, 2); const firstPercent = ordered.length <= 1 ? 100 : job.first_pay_percent; const secondPercent = ordered.length === 1 ? 0 : 100 - firstPercent; // Extra months: weekend shift can move pay across month boundary. let cursor = new Date(Date.UTC(start.year, start.month - 2, 1)); const last = new Date(Date.UTC(end.year, end.month, 1)); while (cursor <= last) { const year = cursor.getUTCFullYear(); const month = cursor.getUTCMonth() + 1; ordered.forEach((scheduledDay, i) => { const nominal = nominalDate(year, month, scheduledDay); const actual = adjustForWeekend(nominal, job.weekend_policy); if (actual < rangeStart || actual > rangeEnd) return; const percent = i === 0 ? firstPercent : secondPercent; const amount = Math.round((job.salary_amount * percent) / 100 * 100) / 100; result.push({ date: actual, jobId: job.id, jobName: job.name, scheduledDay, percent, amount, }); }); cursor = new Date(Date.UTC(year, month, 1)); } } return result.sort((a, b) => a.date === b.date ? a.jobId - b.jobId || a.scheduledDay - b.scheduledDay : a.date < b.date ? -1 : 1, ); } export function monthGrid(year: number, month: number): string[] { const first = toIso(year, month, 1); const startOffset = weekdayMon0(first); const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); const cells: string[] = []; for (let i = 0; i < startOffset; i++) { cells.push(addDaysIso(first, i - startOffset)); } for (let d = 1; d <= daysInMonth; d++) { cells.push(toIso(year, month, d)); } while (cells.length % 7 !== 0 || cells.length < 35) { const last = cells[cells.length - 1]!; cells.push(addDaysIso(last, 1)); if (cells.length >= 42) break; } return cells; }