package seeder import ( "fmt" "math" "sort" "time" ) // A port of src/api/attendanceSeed.js. // // Ported rather than snapshotted because this is the one collection whose dates // are anchored to *now* rather than to a fixed calendar. `dataResolver.inPeriod` // windows every collection on created_date, so "attendance last week" has to // mean last week on the day the seeder runs. A frozen JSON snapshot would read // as permanently empty a fortnight later. // // Nothing here is random. The distribution is deterministic given the date the // seeder runs, so the same day always produces the same figures and the // regression tests can assert against them: // // Marco — the control: reliable, weekend event overtime // Marcus — attendance degrading over the last fortnight (the anomaly) // Antoine — present throughout, overtime climbing week on week (the trend) const windowDays = 56 type rosterEntry struct { staffID, workerName, workerEmail string jobPostingID, role, roleCategory string weekdays []time.Weekday startHour int scheduledHours float64 } var roster = []rosterEntry{ { staffID: "staff_marco", workerName: "Marco Rivera", workerEmail: "marco.rivera@email.com", jobPostingID: "job_bartender_corp", role: "Experienced Bartender – Corporate Events", roleCategory: "Bartender", // Wed–Sat: corporate events run late in the week. weekdays: []time.Weekday{time.Wednesday, time.Thursday, time.Friday, time.Saturday}, startHour: 16, scheduledHours: 8, }, { staffID: "staff_marcus", workerName: "Marcus Williams", workerEmail: "marcus.w@email.com", jobPostingID: "job_security", role: "Event Security Officer", roleCategory: "Security", // Mon–Fri: a fixed rota, which is what makes the recent absences stand // out rather than read as an irregular schedule. weekdays: []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday}, startHour: 14, scheduledHours: 8, }, { staffID: "staff_antoine", workerName: "Chef Antoine Dubois", workerEmail: "antoine.dubois@email.com", jobPostingID: "job_chef", role: "Executive Chef – Catering", roleCategory: "Chef", // Tue–Sat: kitchen service. weekdays: []time.Weekday{time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday}, startHour: 12, scheduledHours: 9, }, } type behaviour struct { status string minutesLate int overtime float64 notes string } // behaviourFor mirrors the BEHAVIOUR map. `i` counts back from the most recent // shift, so "the last fortnight" stays a range of small indices as the window // rolls forward. func behaviourFor(staffID string, i int, weekday time.Weekday) behaviour { switch staffID { case "staff_marco": b := behaviour{status: "present"} if i == 14 { b.status, b.minutesLate = "late", 9 } // Friday and Saturday events overrun; midweek ones do not. if weekday == time.Friday || weekday == time.Saturday { b.overtime = 1 } return b case "staff_marcus": switch i { case 2, 7: return behaviour{status: "absent", notes: "Called in sick"} case 4: return behaviour{status: "no_show", notes: "No contact"} case 1: return behaviour{status: "late", minutesLate: 24} case 5: return behaviour{status: "late", minutesLate: 16} case 9: return behaviour{status: "late", minutesLate: 12} case 26: return behaviour{status: "late", minutesLate: 7} } return behaviour{status: "present"} case "staff_antoine": weekIndex := i / 5 busy := weekday == time.Thursday || weekday == time.Friday || weekday == time.Saturday b := behaviour{status: "present"} if busy { b.overtime = math.Max(0.5, round1(3.5-float64(weekIndex)*0.45)) } return b } return behaviour{status: "present"} } // round1 and round2 reproduce JavaScript's Math.round, which rounds halves away // from zero — the same rule as Go's math.Round. func round1(n float64) float64 { return math.Round(n*10) / 10 } func round2(n float64) float64 { return math.Round(n*100) / 100 } // daysAgo is `n` days back at a given local hour. // // Local rather than UTC because a shift belongs to the day it was worked in the // place it was worked, and periodRange windows on local day boundaries too. func daysAgo(now time.Time, n, hour int) time.Time { d := now.AddDate(0, 0, -n) return time.Date(d.Year(), d.Month(), d.Day(), hour, 0, 0, 0, now.Location()) } func containsWeekday(set []time.Weekday, w time.Weekday) bool { for _, x := range set { if x == w { return true } } return false } // shiftOffsets is every day offset in the window on which this worker is rostered. func shiftOffsets(now time.Time, weekdays []time.Weekday) []int { var offsets []int for offset := 0; offset <= windowDays; offset++ { if containsWeekday(weekdays, daysAgo(now, offset, 0).Weekday()) { offsets = append(offsets, offset) } } return offsets } const isoMillis = "2006-01-02T15:04:05.000Z" func iso(t time.Time) string { return t.UTC().Format(isoMillis) } // BuildShifts generates the shift records for a given instant, most recent first. func BuildShifts(now time.Time) []map[string]any { records := make([]map[string]any, 0, 128) for _, w := range roster { offsets := shiftOffsets(now, w.weekdays) for i, offset := range offsets { scheduledStart := daysAgo(now, offset, w.startHour) weekday := scheduledStart.Weekday() scheduledEnd := scheduledStart.Add(time.Duration(w.scheduledHours * float64(time.Hour))) b := behaviourFor(w.staffID, i, weekday) worked := b.status != "absent" && b.status != "no_show" var actualStart, actualEnd any endForUpdated := scheduledEnd if worked { actualStart = iso(scheduledStart.Add(time.Duration(b.minutesLate) * time.Minute)) ae := scheduledEnd.Add(time.Duration(b.overtime * float64(time.Hour))) actualEnd, endForUpdated = iso(ae), ae } actualHours, overtimeHours, minutesLate := 0.0, 0.0, 0 if worked { actualHours = round2(w.scheduledHours - float64(b.minutesLate)/60 + b.overtime) overtimeHours = round1(b.overtime) minutesLate = b.minutesLate } records = append(records, map[string]any{ "id": fmt.Sprintf("shift_%s_%02d", w.staffID[len("staff_"):], len(offsets)-i), "staff_id": w.staffID, "worker_name": w.workerName, "worker_email": w.workerEmail, "job_posting_id": w.jobPostingID, "role": w.role, "role_category": w.roleCategory, "shift_date": scheduledStart.Format("2006-01-02"), "scheduled_start": iso(scheduledStart), "scheduled_end": iso(scheduledEnd), "scheduled_hours": w.scheduledHours, "actual_start": actualStart, "actual_end": actualEnd, "actual_hours": actualHours, "overtime_hours": overtimeHours, "minutes_late": minutesLate, "status": b.status, "notes": b.notes, // Load-bearing: dataResolver windows every collection on // created_date, so a shift's created date IS the instant it // was worked. "created_date": iso(scheduledStart), "updated_date": iso(endForUpdated), }) } } // Most recent first, matching the -created_date order every other // collection is listed in. sort.SliceStable(records, func(a, b int) bool { return records[a]["created_date"].(string) > records[b]["created_date"].(string) }) return records }