"use strict"; const patterns={ "four-day":{name:"4 on, 4 off — days",cycle:["D","D","D","D","O","O","O","O"],desc:"Four 12-hour day shifts followed by four rest days."}, "four-night":{name:"4 on, 4 off — nights",cycle:["N","N","N","N","O","O","O","O"],desc:"Four 12-hour night shifts followed by four rest days."}, "four-rotating":{name:"4 days, 4 off, 4 nights, 4 off",cycle:["D","D","D","D","O","O","O","O","N","N","N","N","O","O","O","O"],desc:"A common alternating day and night rotation."}, "two-two-three":{name:"2-2-3 / Pitman",cycle:["D","D","O","O","D","D","D","O","O","D","D","O","O","O"],desc:"A 14-day pattern with every other weekend off."}, "dupont":{name:"DuPont",cycle:["N","N","N","N","O","O","O","D","D","D","O","O","O","O","N","N","N","N","O","O","O","D","D","D","D","D","D","D"],desc:"A 28-day rotating schedule with day, night and rest blocks."}, "continental":{name:"Continental — 2-2-2",cycle:["E","E","L","L","N","N","O","O","O","O"],desc:"Two early, two late and two night shifts followed by four rest days."}, "three-three":{name:"3 on, 3 off",cycle:["D","D","D","O","O","O"],desc:"Three work shifts followed by three rest days."}, "seven-seven":{name:"7 on, 7 off",cycle:["D","D","D","D","D","D","D","O","O","O","O","O","O","O"],desc:"Seven consecutive shifts followed by seven rest days."}, "ddnnoo":{name:"2 days, 2 nights, 4 off",cycle:["D","D","N","N","O","O","O","O"],desc:"Two day shifts, two night shifts and four rest days."}, "twentyfour":{name:"24 on, 48 off",cycle:["D","O","O"],desc:"One duty day followed by two days off."}, "fortyeight":{name:"48 on, 96 off",cycle:["D","D","O","O","O","O"],desc:"Two duty days followed by four days off."}, "five-two":{name:"5 on, 2 off",cycle:["D","D","D","D","D","O","O"],desc:"A conventional five-day working week."}, custom:{name:"Custom repeating pattern",cycle:["D","D","N","N","O","O","O","O"],desc:"Create an employer-specific repeating sequence."} }; const names={D:"Day shift",N:"Night shift",E:"Early shift",L:"Late shift",O:"Rest day"}; const el=id=>document.getElementById(id); const pad=n=>String(n).padStart(2,"0"); const iso=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; const localDate=s=>{const [y,m,d]=s.split("-").map(Number);return new Date(y,m-1,d)}; const addDays=(d,n)=>new Date(d.getFullYear(),d.getMonth(),d.getDate()+n); const diffDays=(a,b)=>Math.round((Date.UTC(a.getFullYear(),a.getMonth(),a.getDate())-Date.UTC(b.getFullYear(),b.getMonth(),b.getDate()))/86400000); const nthWeekday=(y,m,weekday,n)=>{const d=new Date(y,m,1);return new Date(y,m,1+((7+weekday-d.getDay())%7)+(n-1)*7)}; const lastWeekday=(y,m,w)=>{const d=new Date(y,m+1,0);return new Date(y,m,d.getDate()-((7+d.getDay()-w)%7))}; const easter=y=>{const a=y%19,b=Math.floor(y/100),c=y%100,d=Math.floor(b/4),e=b%4,f=Math.floor((b+8)/25),g=Math.floor((b-f+1)/3),h=(19*a+b-d-g+15)%30,i=Math.floor(c/4),k=c%4,l=(32+2*e+2*i-h-k)%7,m=Math.floor((a+11*h+22*l)/451),month=Math.floor((h+l-7*m+114)/31)-1,day=(h+l-7*m+114)%31+1;return new Date(y,month,day)}; function holidays(year,region){const h=new Map(),put=(d,n)=>h.set(iso(d),n),fixed=(m,d,n)=>put(new Date(year,m,d),n); const e=easter(year); if(region==="uk"){fixed(0,1,"New Year’s Day");put(addDays(e,-2),"Good Friday");put(addDays(e,1),"Easter Monday");put(nthWeekday(year,4,1,1),"Early May bank holiday");put(lastWeekday(year,4,1),"Spring bank holiday");put(lastWeekday(year,7,1),"Summer bank holiday");fixed(11,25,"Christmas Day");fixed(11,26,"Boxing Day");} else if(region==="us"){fixed(0,1,"New Year’s Day");put(nthWeekday(year,0,1,3),"Martin Luther King Jr. Day");put(nthWeekday(year,1,1,3),"Presidents’ Day");put(lastWeekday(year,4,1),"Memorial Day");fixed(5,19,"Juneteenth");fixed(6,4,"Independence Day");put(nthWeekday(year,8,1,1),"Labor Day");put(nthWeekday(year,10,4,4),"Thanksgiving");fixed(11,25,"Christmas Day");} else if(region==="ca"){fixed(0,1,"New Year’s Day");put(addDays(e,-2),"Good Friday");fixed(6,1,"Canada Day");put(nthWeekday(year,8,1,1),"Labour Day");put(nthWeekday(year,9,1,2),"Thanksgiving");fixed(11,25,"Christmas Day");fixed(11,26,"Boxing Day");} else if(region==="au"){fixed(0,1,"New Year’s Day");fixed(0,26,"Australia Day");put(addDays(e,-2),"Good Friday");put(addDays(e,1),"Easter Monday");fixed(3,25,"Anzac Day");fixed(11,25,"Christmas Day");fixed(11,26,"Boxing Day");} else if(region==="nz"){fixed(0,1,"New Year’s Day");fixed(0,2,"Day after New Year’s Day");fixed(1,6,"Waitangi Day");put(addDays(e,-2),"Good Friday");put(addDays(e,1),"Easter Monday");fixed(3,25,"Anzac Day");fixed(11,25,"Christmas Day");fixed(11,26,"Boxing Day");} else if(region==="ie"){fixed(0,1,"New Year’s Day");fixed(2,17,"St Patrick’s Day");put(addDays(e,1),"Easter Monday");put(nthWeekday(year,4,1,1),"May bank holiday");put(nthWeekday(year,5,1,1),"June bank holiday");put(nthWeekday(year,7,1,1),"August bank holiday");put(lastWeekday(year,9,1),"October bank holiday");fixed(11,25,"Christmas Day");fixed(11,26,"St Stephen’s Day");} else {fixed(0,1,"New Year’s Day");fixed(2,21,"Human Rights Day");put(addDays(e,-2),"Good Friday");put(addDays(e,1),"Family Day");fixed(3,27,"Freedom Day");fixed(4,1,"Workers’ Day");fixed(5,16,"Youth Day");fixed(7,9,"National Women’s Day");fixed(8,24,"Heritage Day");fixed(11,16,"Day of Reconciliation");fixed(11,25,"Christmas Day");fixed(11,26,"Day of Goodwill");} return h} let state={entries:[],cycle:[]}; function cycleNow(){if(el("patternSelect").value!=="custom")return patterns[el("patternSelect").value].cycle;const valid=el("customPattern").value.toUpperCase().split(/[\s,;|/-]+/).filter(x=>names[x]);return valid.length?valid:["D","O"]} function shiftFor(d,anchor,cycle){const i=((diffDays(d,anchor)%cycle.length)+cycle.length)%cycle.length;return cycle[i]} function render(){const anchor=localDate(el("anchorDate").value),months=+el("months").value,cycle=cycleNow(),start=new Date(anchor.getFullYear(),anchor.getMonth(),1),end=new Date(start.getFullYear(),start.getMonth()+months,0),region=el("region").value,weekStart=+el("weekStart").value;state={entries:[],cycle};let html="",work=0,hours=0,pay=0,weekends=new Set();const holidayCache={}; for(let mi=0;mi`
${x}
`).join("");let blanks=(first.getDay()-weekStart+7)%7;cells+='
'.repeat(blanks); for(let n=1;n<=last.getDate();n++){const d=new Date(first.getFullYear(),first.getMonth(),n),code=shiftFor(d,anchor,cycle),hol=hs.get(iso(d)),cls={D:"day-shift",N:"night-shift",E:"early-shift",L:"late-shift",O:"off"}[code];cells+=`
${n}
`;state.entries.push({date:d,code,holiday:hol});if(code!=="O"){work++;const sh=code==="N"?+el("nightHours").value:+el("dayHours").value;hours+=sh;pay+=sh*(+el("hourlyRate").value+(code==="N"?+el("nightPremium").value:0));if(d.getDay()===0||d.getDay()===6)weekends.add(`${d.getFullYear()}-${weekNumber(d)}`)}}html+=`

${first.toLocaleDateString(undefined,{month:"long",year:"numeric"})}

${cells}
`} el("calendar").innerHTML=html;el("statShifts").textContent=work;el("statHours").textContent=hours.toLocaleString(undefined,{maximumFractionDigits:1});const currency={uk:"GBP",us:"USD",ca:"CAD",au:"AUD",nz:"NZD",ie:"EUR",za:"ZAR"}[region];el("statPay").textContent=new Intl.NumberFormat(undefined,{style:"currency",currency,maximumFractionDigits:0}).format(pay);el("statWeekends").textContent=weekends.size;el("resultTitle").textContent=`${months}-month ${patterns[el("patternSelect").value].name} calendar`;updateCheck();localStorage.setItem("shiftPatternSettings",JSON.stringify({pattern:el("patternSelect").value,anchor:el("anchorDate").value,months,region,weekStart}));} function weekNumber(d){const x=new Date(Date.UTC(d.getFullYear(),d.getMonth(),d.getDate()));x.setUTCDate(x.getUTCDate()+4-(x.getUTCDay()||7));return Math.ceil((((x-new Date(Date.UTC(x.getUTCFullYear(),0,1)))/86400000)+1)/7)} function updateCheck(){const s=el("checkDate").value;if(!s||!state.cycle.length)return;const d=localDate(s),anchor=localDate(el("anchorDate").value),code=shiftFor(d,anchor,state.cycle),holiday=holidays(d.getFullYear(),el("region").value).get(s);el("dateAnswer").textContent=code==="O"?`No — ${d.toLocaleDateString(undefined,{weekday:"long",day:"numeric",month:"long"})} is a rest day${holiday?` (${holiday})`:""}.`:`Yes — you are scheduled for a ${names[code].toLowerCase()}${holiday?` on ${holiday}`:""}.`;el("heroAnswer").textContent=code==="O"?"You’re off":"You’re working";el("heroSub").textContent=d.toLocaleDateString(undefined,{weekday:"long",day:"numeric",month:"long",year:"numeric"})} function icsDate(d){return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}`} function downloadIcs(){const workOnly=state.entries.filter(x=>x.code!=="O");const lines=["BEGIN:VCALENDAR","VERSION:2.0","PRODID:-//WorkPattern//Rota Calendar//EN","CALSCALE:GREGORIAN","METHOD:PUBLISH","X-WR-CALNAME:My Work Pattern"];for(const x of workOnly){const next=addDays(x.date,1);lines.push("BEGIN:VEVENT",`UID:${icsDate(x.date)}-${x.code}@workpattern.net`,`DTSTAMP:${icsDate(new Date())}T120000Z`,`DTSTART;VALUE=DATE:${icsDate(x.date)}`,`DTEND;VALUE=DATE:${icsDate(next)}`,`SUMMARY:${names[x.code]}`,`DESCRIPTION:Generated by WorkPattern${x.holiday?` - ${x.holiday}`:""}`,"TRANSP:OPAQUE","END:VEVENT")}lines.push("END:VCALENDAR");const blob=new Blob([lines.join("\r\n")],{type:"text/calendar;charset=utf-8"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="my-work-pattern.ics";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)} function showHelp(type){const i=el("instructions");i.hidden=false;i.innerHTML=type==="google"?"Google Calendar: Download the .ics file, open Google Calendar on a computer, choose Settings → Import & export → Import, select the file and choose your calendar.":"Outlook: Download the .ics file, open Outlook Calendar, choose Add calendar → Upload from file, select the file and choose the destination calendar. Apple Calendar users can simply open the downloaded file.";i.scrollIntoView({behavior:"smooth",block:"nearest"})} function init(){Object.entries(patterns).forEach(([k,v])=>el("patternSelect").add(new Option(v.name,k)));const saved=JSON.parse(localStorage.getItem("shiftPatternSettings")||"{}");el("patternSelect").value=saved.pattern||"four-rotating";el("anchorDate").value=saved.anchor||iso(new Date());el("checkDate").value=iso(new Date());el("months").value=saved.months||12;el("region").value=saved.region||"uk";el("weekStart").value=saved.weekStart??1;patternChanged();render();el("year").textContent=new Date().getFullYear()} function patternChanged(){const p=patterns[el("patternSelect").value];el("patternDescription").textContent=p.desc;el("customWrap").hidden=el("patternSelect").value!=="custom"} el("patternSelect").addEventListener("change",()=>{patternChanged();render()});el("generateBtn").addEventListener("click",render);el("checkDate").addEventListener("change",updateCheck);el("printBtn").addEventListener("click",()=>print());el("downloadIcs").addEventListener("click",downloadIcs);el("googleHelp").addEventListener("click",()=>showHelp("google"));el("outlookHelp").addEventListener("click",()=>showHelp("outlook"));init();