first commit
This commit is contained in:
215
go-api/internal/seeder/shifts.go
Normal file
215
go-api/internal/seeder/shifts.go
Normal file
@@ -0,0 +1,215 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user