216 lines
7.3 KiB
Go
216 lines
7.3 KiB
Go
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
|
||
}
|