class_name ShiftInstance extends RefCounted ## One concrete shift, identified by its start day even when it ends on the ## following day. Absolute integer minutes avoid every midnight ambiguity. enum Kind { DAY, NIGHT, FLEX, } const MINUTES_PER_DAY := 1_440 const COMMUTE_LEAD_MINUTES := 30 const DAY_START := 360 const DAY_END := 1_080 const NIGHT_START := 1_080 const NIGHT_END := 360 var shift_instance_id: StringName = &"" var staff_id: StringName = &"" var template_id: StringName = &"FLEX" var kind: Kind = Kind.FLEX var start_day := 0 var start_minute_of_day := 0 var end_minute_of_day := 1 var start_at := 0 var end_at := 1 var assigned_facility_id: StringName = &"" var cancelled := false var actual_started_at := -1 var actual_ended_at := -1 var worked_minutes := 0 var overtime_minutes := 0 var late_minutes := 0 var late_reason: StringName = &"" func configure( p_shift_instance_id: StringName, p_staff_id: StringName, p_template_id: StringName, p_start_day: int, p_start_minute_of_day: int, p_end_minute_of_day: int, p_assigned_facility_id: StringName = &"", options: Dictionary = {} ) -> ShiftInstance: shift_instance_id = p_shift_instance_id staff_id = p_staff_id template_id = p_template_id if not p_template_id.is_empty() else &"FLEX" kind = _kind_from_template(template_id) start_day = maxi(p_start_day, 0) start_minute_of_day = p_start_minute_of_day end_minute_of_day = p_end_minute_of_day start_at = start_day * MINUTES_PER_DAY + start_minute_of_day end_at = start_day * MINUTES_PER_DAY + end_minute_of_day if end_minute_of_day <= start_minute_of_day: end_at += MINUTES_PER_DAY assigned_facility_id = p_assigned_facility_id cancelled = bool(_value(options, &"cancelled", false)) actual_started_at = int(_value(options, &"actual_started_at", -1)) actual_ended_at = int(_value(options, &"actual_ended_at", -1)) worked_minutes = maxi(int(_value(options, &"worked_minutes", 0)), 0) overtime_minutes = maxi(int(_value(options, &"overtime_minutes", 0)), 0) late_minutes = maxi(int(_value(options, &"late_minutes", 0)), 0) late_reason = StringName(_value(options, &"late_reason", &"")) return self static func create_standard( p_shift_instance_id: StringName, p_staff_id: StringName, p_template_id: StringName, p_start_day: int, p_assigned_facility_id: StringName = &"" ) -> ShiftInstance: var normalized := String(p_template_id).to_upper() if normalized == "DAY": return ShiftInstance.new().configure( p_shift_instance_id, p_staff_id, &"DAY", p_start_day, DAY_START, DAY_END, p_assigned_facility_id ) if normalized == "NIGHT": return ShiftInstance.new().configure( p_shift_instance_id, p_staff_id, &"NIGHT", p_start_day, NIGHT_START, NIGHT_END, p_assigned_facility_id ) return null func is_valid() -> bool: return ( not shift_instance_id.is_empty() and not staff_id.is_empty() and start_day >= 0 and start_minute_of_day >= 0 and start_minute_of_day < MINUTES_PER_DAY and end_minute_of_day >= 0 and end_minute_of_day < MINUTES_PER_DAY and end_at > start_at and end_at - start_at <= MINUTES_PER_DAY and worked_minutes >= 0 and overtime_minutes >= 0 and late_minutes >= 0 ) func crosses_midnight() -> bool: return end_at / MINUTES_PER_DAY > start_at / MINUTES_PER_DAY func contains_minute(absolute_minute: int) -> bool: return not cancelled and absolute_minute >= start_at and absolute_minute < end_at func is_commute_window(absolute_minute: int) -> bool: return ( not cancelled and absolute_minute >= start_at - COMMUTE_LEAD_MINUTES and absolute_minute < start_at ) func has_ended(absolute_minute: int) -> bool: return cancelled or absolute_minute >= end_at func record_duty_minute(absolute_minute: int, overtime := false) -> bool: if actual_started_at < 0: actual_started_at = absolute_minute if overtime: if absolute_minute < end_at: return false overtime_minutes += 1 return true if not contains_minute(absolute_minute): return false worked_minutes += 1 return true func close(absolute_minute: int) -> void: if actual_ended_at < 0: actual_ended_at = maxi(absolute_minute, start_at) func mark_late(absolute_minute: int, reason: StringName) -> bool: if absolute_minute < start_at + 15: return false late_minutes = maxi(absolute_minute - start_at, 15) late_reason = reason return true func create_snapshot() -> Dictionary: return { &"shift_instance_id": String(shift_instance_id), &"staff_id": String(staff_id), &"template_id": String(template_id), &"kind": Kind.keys()[kind], &"start_day": start_day, &"start_minute_of_day": start_minute_of_day, &"end_minute_of_day": end_minute_of_day, &"start_at": start_at, &"end_at": end_at, &"assigned_facility_id": String(assigned_facility_id), &"cancelled": cancelled, &"actual_started_at": actual_started_at, &"actual_ended_at": actual_ended_at, &"worked_minutes": worked_minutes, &"overtime_minutes": overtime_minutes, &"late_minutes": late_minutes, &"late_reason": String(late_reason), } static func from_snapshot(snapshot: Dictionary) -> ShiftInstance: if not _snapshot_shape_is_valid(snapshot): return null var shift := ShiftInstance.new().configure( StringName(snapshot.get(&"shift_instance_id", "")), StringName(snapshot.get(&"staff_id", "")), StringName(snapshot.get(&"template_id", "FLEX")), int(snapshot.get(&"start_day", 0)), int(snapshot.get(&"start_minute_of_day", 0)), int(snapshot.get(&"end_minute_of_day", 1)), StringName(snapshot.get(&"assigned_facility_id", "")), { &"cancelled": snapshot.get(&"cancelled", false), &"actual_started_at": snapshot.get(&"actual_started_at", -1), &"actual_ended_at": snapshot.get(&"actual_ended_at", -1), &"worked_minutes": snapshot.get(&"worked_minutes", 0), &"overtime_minutes": snapshot.get(&"overtime_minutes", 0), &"late_minutes": snapshot.get(&"late_minutes", 0), &"late_reason": snapshot.get(&"late_reason", ""), } ) if shift.start_at != int(snapshot.get(&"start_at", -1)): return null if shift.end_at != int(snapshot.get(&"end_at", -1)): return null var kind_index := Kind.keys().find(String(snapshot.get(&"kind", ""))) if kind_index < 0 or shift.kind != kind_index: return null return shift if shift.is_valid() else null static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool: return ( not String(snapshot.get(&"shift_instance_id", "")).is_empty() and not String(snapshot.get(&"staff_id", "")).is_empty() and int(snapshot.get(&"start_day", -1)) >= 0 and int(snapshot.get(&"worked_minutes", -1)) >= 0 and int(snapshot.get(&"overtime_minutes", -1)) >= 0 ) static func _kind_from_template(value: StringName) -> Kind: match String(value).to_upper(): "DAY": return Kind.DAY "NIGHT": return Kind.NIGHT _: return Kind.FLEX static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: return source.get(key, source.get(String(key), fallback))