Files
krow_backend/go-api/internal/seeder/shifts.go
2026-08-24 13:06:29 +05:30

216 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}